From 46471ea43bc77803e42206c6d7f52cb5a19cfab6 Mon Sep 17 00:00:00 2001 From: Rodrigo de Oliveira Date: Mon, 27 Jul 2026 07:20:17 -0300 Subject: [PATCH 01/49] docs(sdd): define generated materialization architecture --- ...-generated-materialization-architecture.md | 525 ++++++++++++++++++ .sdd/etapa-7/DECISIONS.md | 171 ++++++ .sdd/etapa-7/STATUS.md | 73 +++ 3 files changed, 769 insertions(+) create mode 100644 .sdd/etapa-7/01-generated-materialization-architecture.md create mode 100644 .sdd/etapa-7/DECISIONS.md create mode 100644 .sdd/etapa-7/STATUS.md diff --git a/.sdd/etapa-7/01-generated-materialization-architecture.md b/.sdd/etapa-7/01-generated-materialization-architecture.md new file mode 100644 index 0000000..6c03eec --- /dev/null +++ b/.sdd/etapa-7/01-generated-materialization-architecture.md @@ -0,0 +1,525 @@ +# Arquitetura de Materializacao Gerada + +Status: SPECIFICATION +Prompt: 7.1 +Data: 2026-07-27 + +## Problema + +`QueryMapped*` e hoje o caminho opt-in do FluentMap para materializacao avancada. Ele permite que a biblioteca controle a transformacao: + +```text +IDataReader / IDataRecord -> metadata de mapping -> object graph +``` + +Esse caminho suporta objetos aninhados, Value Objects imutaveis, constructor mapping e mapping profiles, mas depende de metadata em runtime, reflection e `Expression.Compile` para montar o plano de materializacao. Isso aparece principalmente em: + +- `src/Dapper.FluentMap/QueryMappedExtensions.cs`; +- `src/Dapper.FluentMap/MappingRegistry.cs`; +- `src/Dapper.FluentMap/Materialization/NestedMaterializationPlan.cs`; +- `src/Dapper.FluentMap/Compatibility/DapperTypeHandlerAdapter.cs`. + +O custo e o risco atual estao concentrados em: + +- criacao de `DefaultTypeMap`, acesso a `PropertyInfo`, `FieldInfo`, `ConstructorInfo` e `ParameterInfo`; +- factories, getters, setters, conversores e chamadas de construtor compilados com expression trees; +- `Activator.CreateInstance` para defaults de value types e criacao de maps por assembly scanning; +- integracao delicada com TypeHandlers do Dapper via `SqlMapper.TypeHandlerCache.Parse(object)`; +- cache de planos por tipo, profile e shape ordenado de colunas; +- annotations publicas `RequiresUnreferencedCode` e `RequiresDynamicCode` nos helpers `QueryMapped*`. + +O objetivo da Etapa 7 e especificar uma evolucao incremental para substituir parte desse caminho por codigo gerado quando a configuracao for estaticamente conhecida, sem remover o materializador runtime. + +## Objetivos + +- Definir uma arquitetura para materializadores gerados por source generator. +- Preservar o foco do FluentMap em mapping metadata e materializacao de object graph, nao em ORM. +- Permitir que `QueryMapped*` use codigo gerado para casos elegiveis. +- Manter fallback runtime para configuracoes dinamicas, assembly scanning, conventions customizadas e shapes nao gerados. +- Reduzir dependencias de reflection e dynamic code no caminho gerado. +- Criar uma base para validacao futura de performance, trimming e Native AOT. +- Preservar compatibilidade publica e comportamento observavel existente. + +## Nao Objetivos + +- Nao recriar Dapper.AOT. +- Nao substituir `Dapper.Query()` nem o type map normal instalado por `SqlMapper.SetTypeMap`. +- Nao adicionar ORM, CRUD, SQL generator, LINQ provider, migrations, Unit of Work ou change tracking. +- Nao tornar o source generator obrigatorio para consumidores atuais. +- Nao remover `QueryMapped*` runtime. +- Nao declarar suporte Native AOT completo antes de publish/run real. +- Nao gerar materializers para DSL dinamica, construtores de maps arbitrarios ou conventions customizadas nesta especificacao. +- Nao alterar comportamento publico nesta fase. + +## Arquitetura Atual + +### Caminho Dapper normal + +`FluentMapper.Initialize(...)` registra maps e conventions no `MappingRegistry`. Para maps default e conventions por entidade, o registry instala um `FluentMapTypeMap` no Dapper: + +```text +FluentMapTypeMap + -> FluentConstructorTypeMap + -> DapperFluentPropertyTypeMap + -> DefaultTypeMap +``` + +Esse caminho continua sendo o padrao para: + +```csharp +connection.Query(sql); +connection.QuerySingle(sql); +``` + +Ele cobre mapeamentos root-level e constructor mapping simples, mas nao materializa object graphs aninhados. + +### Caminho QueryMapped runtime + +`QueryMappedExtensions` executa o comando usando Dapper (`SqlMapper.ExecuteReader` / `ExecuteReaderAsync`), coleta os nomes de colunas do `IDataReader`, pede ao `MappingRegistry` um `NestedMaterializationPlan` e materializa todas as linhas em uma lista bufferizada. + +```mermaid +flowchart TD + A["QueryMapped*"] --> B["Dapper ExecuteReader"] + B --> C["Ler nomes das colunas"] + C --> D["MappingRegistry.GetMaterializationPlan"] + D --> E["NestedMaterializationPlan cacheado"] + E --> F["plan.Materialize(IDataRecord) por linha"] + F --> G["List bufferizada"] +``` + +O cache atual usa: + +```text +EntityType + ProfileType + ordered column names +``` + +O `NestedMaterializationPlan`: + +- resolve o mapping efetivo por coluna; +- usa profile quando `QueryMapped()` e chamado; +- preserva `MemberPath` completo, nao apenas o nome terminal; +- aplica explicit mapping e depois fallback `DefaultTypeMap` para colunas nao mapeadas; +- constroi subarvores por semantica de `DBNull`; +- escolhe construtores publicos compativeis para objetos imutaveis e Value Objects; +- compila factories, getters, setters e construtores com `Expression.Compile`; +- usa `DapperTypeHandlerAdapter` quando ha TypeHandler Dapper para o tipo alvo. + +### Source generator atual + +`Dapper.FluentMap.Generators` gera apenas registro de mappings: + +```csharp +// API existente emitida pelo generator atual. +configuration.AddGeneratedMappings(); +``` + +Ele descobre maps elegiveis na compilacao atual e emite chamadas a `AddMap()` ou `AddProfile()`. Ele nao interpreta completamente o corpo dos maps, nao gera materializers e nao substitui `FluentMapper.Validate()`. + +### Analyzers atuais + +`Dapper.FluentMap.Analyzers` valida parte da DSL em compile-time: + +- expressoes `Map(...)` que precisam virar property path; +- duplicidade de member path; +- duplicidade de coluna literal; +- `IncludeBase()` invalido; +- `AddMap()` e `AddProfile()` genericos invalidos; +- duplicidade de profile registrado em um metodo de configuracao. + +Essa capacidade mostra que ha um subconjunto da DSL que pode ser reconhecido estaticamente, mas ela nao executa construtores de maps. + +## Custos e Dependencias Substituiveis Futuramente + +| Area atual | Local principal | Custo/dependencia | Substituivel por geracao? | +| --- | --- | --- | --- | +| Shape de colunas | `QueryMappedExtensions.GetColumnNames` | leitura runtime necessaria por query | Parcial. O shape real sempre vem do reader. | +| Lookup de plano | `MappingRegistry.GetMaterializationPlan` | cache por tipo/profile/colunas | Sim, com registry de materializers gerados antes do fallback. | +| Resolucao de member path | `NestedMaterializationPlan.Create` | `PropertyInfo`, `DefaultTypeMap` | Sim para maps estaticos; fallback para default Dapper/conventions dinamicas. | +| Getters/setters | `CreateGetter`, `CreatePropertySetter`, `CreateFieldSetter` | `Expression.Compile` | Sim, chamadas diretas no codigo gerado. | +| Construtores | `CreateConstructorFactory`, `SelectConstructor` | reflection + delegate compilado | Sim para construtores publicos conhecidos via symbols. | +| Conversao escalar | `CreateConverter`, `ConvertValue` | delegates e reflection para enum/Guid/default | Parcial; regras comuns geraveis, TypeHandler exige decisao propria. | +| TypeHandler Dapper | `DapperTypeHandlerAdapter` | reflection em tipo nested do Dapper + `Expression.Compile` | Em aberto. Precisa boundary estavel. | +| Defaults de value type | `Activator.CreateInstance` | reflection-ish runtime | Sim em codigo generico/typed ou `default(T)`. | +| Assembly scanning | `FluentMapConfiguration.AddMapsFromAssembly` | RUC e reflection discovery | Nao para scanning em si; fallback permanece. | + +## Arquitetura Proposta + +A arquitetura proposta adiciona uma camada de materializers gerados antes do `NestedMaterializationPlan`. Essa camada e proposta futura, nao existe no codigo atual. + +```mermaid +flowchart TD + A["QueryMapped*"] --> B["Dapper ExecuteReader"] + B --> C["ColumnShape: nomes ordenados"] + C --> D{"Generated materializer registrado e compativel?"} + D -- "sim" --> E["Generated row materializer"] + D -- "nao" --> F["Runtime NestedMaterializationPlan"] + E --> G["Object graph"] + F --> G +``` + +### Conceito proposto: materializer gerado + +Proposta futura de contrato conceitual: + +```csharp +// Proposta futura. Nao existe hoje. +internal delegate object GeneratedRowMaterializer(IDataRecord record); +``` + +O materializer gerado deve receber somente um `IDataRecord` ou `IDataReader` ja posicionado na linha atual e devolver uma entidade materializada. Ele nao deve: + +- abrir conexao; +- executar SQL; +- criar commands; +- interpretar parametros; +- fazer tracking; +- gerar SQL; +- depender de APIs internas instaveis do Dapper. + +### Conceito proposto: descriptor gerado + +Proposta futura: + +```csharp +// Proposta futura. Nao existe hoje. +internal sealed class GeneratedMaterializerDescriptor +{ + // EntityType + // ProfileType opcional + // ColumnShape esperado + // assinatura/hash do mapping estatico usado na geracao + // delegate de materializacao por linha +} +``` + +O descriptor precisa carregar informacao suficiente para responder: + +- a entidade e o profile batem? +- o shape ordenado de colunas bate? +- a configuracao efetiva ainda corresponde ao mapping usado na geracao? +- o materializer pode ser usado sem fallback? + +O shape deve continuar considerando a ordem das colunas, porque o codigo gerado pode usar ordinais fixos. + +## Fronteira Entre Dapper e FluentMap + +Dapper continua responsavel por: + +- executar comandos; +- gerenciar parametros; +- abrir `IDataReader` por `SqlMapper.ExecuteReader`; +- manter o comportamento de `Query()` e `QuerySingle()`; +- aplicar TypeHandlers em seus caminhos normais; +- fornecer o type map publico usado no caminho root-level. + +FluentMap deve ser responsavel por: + +- resolver metadata efetiva de mapping; +- escolher entre materializer gerado e fallback runtime; +- materializar object graphs quando o usuario opta por `QueryMapped*`; +- preservar profiles query-scoped; +- diagnosticar por que um shape usa generated ou fallback. + +O source generator nao deve chamar APIs internas do Dapper nem gerar um pipeline de consulta. O alvo e apenas: + +```text +IDataRecord -> entidade +``` + +## Responsabilidades do Runtime + +Na arquitetura proposta, o runtime do core deve: + +- manter as APIs publicas existentes; +- continuar executando `QueryMapped*` via Dapper para obter o reader; +- construir a chave `EntityType + ProfileType + ColumnShape`; +- procurar descriptor gerado antes de montar um `NestedMaterializationPlan`; +- validar que o descriptor gerado corresponde ao mapping efetivo registrado; +- usar fallback runtime quando nao houver match seguro; +- manter invalidacao de caches quando mapas/conventions sao alterados via APIs de registro; +- preservar as annotations RUC/RDC enquanto qualquer chamada puder cair no fallback runtime; +- expor diagnostics futuros sem prometer uso de generated quando nao for garantido. + +## Responsabilidades do Source Generator + +O generator futuro deve: + +- continuar emitindo `AddGeneratedMappings()` de forma compativel; +- descobrir somente maps na compilacao atual; +- interpretar apenas um subconjunto estatico da DSL; +- nunca executar construtores de maps; +- gerar descriptors/materializers para maps com metadata estaticamente conhecida; +- emitir diagnostics informativos para maps nao geraveis, sem quebrar fallback; +- representar profiles por `TProfile`; +- preservar ordem de registro compatibilizada com `IncludeBase()`; +- gerar codigo direto para construtores publicos, setters publicos e paths conhecidos; +- evitar dependencia em reflection ou dynamic code no hot path gerado. + +Subconjunto inicial recomendado: + +- `Map(x => x.Property)` e `Map(x => x.Nested.Property)`; +- `ToColumn("literal")`; +- `Ignore()`; +- `IncludeBase()` quando base map tambem for geravel; +- `IProfileMap`; +- construtores publicos cujo binding por nome/tipo seja estaticamente determinavel. + +Fora do subconjunto inicial: + +- nomes de coluna calculados; +- helper methods arbitrarios dentro do map; +- maps com estado de instancia externo; +- assembly scanning; +- conventions customizadas; +- `NamingPolicy.Custom`; +- TypeHandlers sem boundary definida; +- factory methods de Value Objects. + +## Contrato Esperado do Materializer Gerado + +O materializer gerado deve preservar estes contratos comportamentais do runtime atual: + +- explicit mappings tem precedencia sobre fallback; +- profile e selecionado por operacao `QueryMapped()`; +- profile nao altera `SqlMapper.SetTypeMap` global; +- `MemberPath` completo identifica o mapping; +- paths como `Rank.Level` e `Seniority.Level` nao colidem; +- colunas ignoradas nao sao materializadas; +- colunas nao cobertas pelo FluentMap podem continuar usando fallback quando seguro; +- exceptions de dominio durante constructor materialization devem ser encapsuladas com contexto de mapping; +- comportamento publico de `Single()`, buffering e exceptions LINQ dos helpers atuais deve permanecer. + +Como proposta futura, um materializer gerado deve ser tratado como uma otimizacao de execucao, nao como um novo contrato funcional para o usuario. + +## Estrategia de Fallback + +Fallback e obrigatorio. + +Regras propostas: + +- se nao houver descriptor gerado para entity/profile/shape, usar `NestedMaterializationPlan`; +- se o descriptor existir mas a configuracao efetiva nao corresponder, usar fallback; +- se qualquer parte do mapping exigir convention dinamica, scanning ou metadata nao gerada, usar fallback; +- se o generator encontrar uma configuracao parcialmente geravel, gerar diagnostics informativos e deixar o runtime cobrir; +- se o fallback for usado, manter exatamente as mesmas validations e exceptions atuais. + +```mermaid +flowchart TD + A["Entity + Profile + ColumnShape"] --> B{"Descriptor gerado existe?"} + B -- "nao" --> F["Fallback runtime"] + B -- "sim" --> C{"Assinatura do mapping bate?"} + C -- "nao" --> F + C -- "sim" --> D{"Feature suportada pelo generated path?"} + D -- "nao" --> F + D -- "sim" --> E["Generated materializer"] +``` + +## Comportamento Sem Codigo Gerado + +Quando o consumidor nao referencia o pacote generator, quando um map nao e elegivel ou quando a query tem shape nao gerado: + +- `QueryMapped*` continua funcionando como hoje; +- as annotations RUC/RDC continuam corretas; +- nenhuma configuracao existente passa a exigir generator; +- `FluentMapper.Initialize`, `Validate`, `Explain`, `GetEntityMaps` e `GetTypeConventions` continuam funcionando; +- Dapper default mapping continua sendo fallback para `Query()`. + +## Profiles + +Profiles sao bons candidatos para geracao porque ja possuem identidade fortemente tipada: + +```text +EntityType + ProfileType + ColumnShape +``` + +Requisitos: + +- default map e profile map devem ter descriptors separados; +- `QueryMapped()` deve usar o default; +- `QueryMapped()` deve procurar materializer do profile; +- ausencia de profile registrado deve continuar gerando `FluentMapConfigurationException`; +- profiles gerados nao podem vazar para o type map global do Dapper; +- inherited profile maps devem respeitar a ordem e a validacao ja existentes. + +## Nested Objects + +Para objetos aninhados mutaveis, codigo gerado pode emitir chamadas diretas equivalentes a: + +```text +if any column in subtree is non-null: + create/reuse nested object when supported + assign leaves +else: + assign null to subtree when assignable +``` + +Requisitos: + +- manter semantica atual de subarvore toda `NULL`; +- preservar uso de objeto intermediario existente quando a regra atual usa getter + setter; +- rejeitar paths sem construtor/setter publico quando nao houver constructor binding compativel; +- nao declarar suporte a objetos intermediarios privados ou factories sem nova API explicita. + +## Immutable Objects + +Objetos imutaveis devem ser construidos bottom-up quando o construtor publico puder ser selecionado por parametros compativeis. + +Requisitos: + +- selecionar construtor por nome de parametro e compatibilidade de tipo, espelhando a regra runtime; +- tratar ambiguidade como erro deterministico; +- exigir que propriedades sem setter estejam ligadas a construtor; +- encapsular falhas do construtor com contexto de tipo, member path e colunas; +- manter fallback para casos que dependam de runtime metadata. + +## Value Objects + +Value Objects mapeados por componentes devem seguir a mesma estrategia de imutaveis: + +```text +component columns -> Value Object constructor -> property/root constructor +``` + +Requisitos: + +- subarvore toda `NULL` vira `null` quando o destino aceita null; +- subarvore parcialmente preenchida cria o Value Object; +- factory methods continuam fora do contrato atual; +- TypeHandler segue sendo recomendado para Value Object escalar mapeado como propriedade inteira. + +## Null Semantics + +O caminho gerado deve preservar: + +- `DBNull` em reference type ou nullable vira `null`; +- `DBNull` em value type nao anulavel segue o default usado pelo runtime atual; +- subarvore aninhada toda `NULL` nao cria objeto; +- subarvore parcialmente preenchida cria objeto e converte cada folha; +- constructor args devem receber `null`/default conforme o tipo alvo. + +## Diagnostics + +Diagnostics futuros devem ser claros, mas nao devem transformar fallback em erro. + +Possiveis pontos de diagnostico: + +- map elegivel para materializer gerado; +- map nao geravel e motivo; +- query shape sem descriptor; +- descriptor encontrado mas invalido pela configuracao efetiva; +- `Explain` futuro indicando materializacao `Generated` vs `Runtime` como proposta. + +Importante: nao documentar `Generated` como valor existente de `MappingMaterialization`; hoje os valores reais cobrem Dapper, Nested e ValueObject. + +## Trimming + +O generated path deve evitar: + +- assembly scanning; +- `Activator.CreateInstance` para materializacao; +- `Expression.Compile`; +- reflection para acessar members no hot path. + +Ainda assim: + +- APIs que podem cair no fallback devem continuar anotadas; +- explicit/generated registration continua sendo o caminho preferencial em trimmed apps; +- materializers gerados para maps estaticos podem reduzir warnings em cenarios futuros, mas isso precisa de smoke dedicado. + +## Native AOT + +O generated path deve ser projetado para Native AOT, mas a compatibilidade so pode ser declarada depois de validacao real. + +Requisitos para etapas futuras: + +- publish AOT em ambiente com toolchain nativa disponivel; +- executar queries reais com SQLite ou provider compatvel; +- comparar generated path e fallback; +- revisar warnings do FluentMap e warnings herdados do Dapper; +- manter fallback anotado enquanto existir possibilidade de dynamic code. + +## Backward Compatibility + +A evolucao pode ser compativel se: + +- nenhuma API atual for removida; +- `QueryMapped*` continuar funcionando sem generator; +- `AddGeneratedMappings()` continuar registrando maps como hoje; +- maps dinamicos continuarem suportados pelo fallback; +- `FluentMapper.EntityMaps` e `TypeConventions` continuarem existindo; +- `Dapper.Query()` nao mudar; +- Dommel nao for envolvido automaticamente. + +Qualquer API publica nova para registro de materializers gerados deve ser pequena, aditiva e versionada com cuidado. + +## Extensibilidade Futura + +Areas possiveis, fora da primeira implementacao: + +- manifests por assembly para materializers de dependencias; +- diagnostico publico de cobertura generated/fallback; +- API explicita para factory methods de Value Objects; +- boundary publica para conversao via TypeHandler sem reflection interna do Dapper; +- generated support para naming policies built-in registradas estaticamente; +- streaming/unbuffered baseado em materializer por linha. + +## Riscos + +- O generator interpretar demais a DSL e gerar codigo incorreto. +- Fallback pouco visivel gerar expectativa errada de performance/AOT. +- Public mutable dictionaries invalidarem descriptors gerados. +- TypeHandlers exigirem acoplamento maior ao Dapper. +- Conventions customizadas limitarem a cobertura gerada. +- Codigo gerado duplicar regras do runtime e divergir com o tempo. +- Ganhos de performance permanecerem hipotese sem benchmark. +- Native AOT ser bloqueado por dependencias fora do FluentMap. + +## Decisoes Ainda em Aberto + +- Qual sera a menor API/runtime boundary para registrar descriptors gerados? +- O descriptor deve ser publico, internal com `InternalsVisibleTo`, ou emitido no namespace do core? +- Como validar assinatura de mapping efetivo sem depender de strings frageis? +- Como representar fallback diagnostics sem poluir a API publica? +- Como integrar TypeHandlers Dapper sem depender de detalhes internos? +- Naming policies built-in entram na primeira fase gerada ou ficam para depois? +- Havera suporte a manifests de assemblies referenciados? +- Em que momento as annotations RUC/RDC poderiam ser relaxadas, se algum dia puderem? + +## Plano Incremental da Etapa 7 + +1. Benchmarks + - Criar baseline comparando Dapper default, `QueryMapped*` runtime e PoC/generated-like. + - Medir first query, throughput por linha, alocacao e custo de cache. +2. Contratos runtime + - Definir chave, descriptor, lookup, validacao de correspondencia e fallback. + - Manter API publica aditiva e pequena. +3. Flat/simple materialization + - Gerar materializers para propriedades root simples com `ToColumn("literal")`. + - Cobrir default profile e profiles explicitos. +4. Nested/value object materialization + - Gerar nested mutable objects, imutaveis e Value Objects por construtores publicos. + - Preservar null semantics e exceptions com contexto. +5. Runtime integration + - Integrar lookup generated antes do `NestedMaterializationPlan`. + - Adicionar diagnostics de generated/fallback. +6. AOT/performance validation + - Executar trimmed smoke, Native AOT smoke e benchmarks formais. + - So entao documentar ganhos ou reducao de warnings. + +## Arquivos Relevantes + +- `README.md` +- `Dapper.FluentMap.sln` +- `src/Dapper.FluentMap/QueryMappedExtensions.cs` +- `src/Dapper.FluentMap/MappingRegistry.cs` +- `src/Dapper.FluentMap/Materialization/NestedMaterializationPlan.cs` +- `src/Dapper.FluentMap/Materialization/MaterializationPlanCacheKey.cs` +- `src/Dapper.FluentMap/Mapping/MemberPath.cs` +- `src/Dapper.FluentMap/Mapping/EntityMap.cs` +- `src/Dapper.FluentMap/Compatibility/DapperTypeHandlerAdapter.cs` +- `src/Dapper.FluentMap.Generators/MappingRegistrationGenerator.cs` +- `src/Dapper.FluentMap.Analyzers/FluentMapConfigurationAnalyzer.cs` +- `test/Dapper.FluentMap.Tests/GeneratedMaterializerSpikeTests.cs` +- `test/Dapper.FluentMap.GeneratedRegistration.Tests/GeneratedRegistrationIntegrationTests.cs` +- `test/Dapper.FluentMap.AotSmoke/Program.cs` +- `docs/sdd/etapa-6/04-generated-materializer-spike.md` diff --git a/.sdd/etapa-7/DECISIONS.md b/.sdd/etapa-7/DECISIONS.md new file mode 100644 index 0000000..a9d5acd --- /dev/null +++ b/.sdd/etapa-7/DECISIONS.md @@ -0,0 +1,171 @@ +# Etapa 7 Decisions + +Status: ACTIVE +Prompt: 7.1 + +## ADR-7.1-001 - Generated Materializer Complementa o Runtime Materializer + +### Contexto + +`QueryMapped*` ja suporta nested objects, constructor mapping, Value Objects e profiles por meio de `NestedMaterializationPlan`. Esse caminho e funcional e cobre configuracoes dinamicas, mas usa reflection e dynamic code. + +### Decisao + +Materializadores gerados serao uma otimizacao complementar. O runtime materializer permanece como fallback autoritativo. + +### Alternativas + +- Substituir completamente `NestedMaterializationPlan`. +- Criar uma nova API separada apenas para generated materialization. +- Manter runtime-only e nao evoluir geracao. + +### Consequencias + +- Compatibilidade com consumidores atuais e preservada. +- A implementacao pode evoluir por fases. +- O core tera dois caminhos de materializacao que precisam de testes de equivalencia. +- APIs que podem cair no fallback continuam precisando de annotations de trimming/dynamic code. + +## ADR-7.1-002 - Localizacao por Entity, Profile e ColumnShape + +### Contexto + +O cache atual de materializacao usa tipo da entidade, profile opcional e nomes de colunas ordenados. O codigo gerado tende a usar ordinais fixos, portanto a ordem das colunas importa. + +### Decisao + +Materializers gerados devem ser localizados por: + +```text +EntityType + ProfileType opcional + ColumnShape ordenado +``` + +O descriptor gerado tambem deve carregar uma assinatura do mapping estatico usado na geracao. + +### Alternativas + +- Localizar apenas por entidade/profile. +- Localizar por entidade/profile e conjunto de colunas sem ordem. +- Escolher materializer por nome do metodo gerado em chamada explicita do usuario. + +### Consequencias + +- Evita usar ordinais incorretos. +- Preserva isolamento de profiles. +- Permite fallback para shapes inesperados. +- Pode gerar multiplos descriptors por entidade/profile em fases futuras. + +## ADR-7.1-003 - Evitar Acoplamento Excessivo ao Dapper + +### Contexto + +O FluentMap integra com Dapper por contratos publicos como `SqlMapper.ITypeMap`, `SqlMapper.IMemberMap`, `ExecuteReader` e TypeHandlers. A compatibilidade com TypeHandlers hoje usa um adapter isolado porque toca em uma area sensivel do Dapper. + +### Decisao + +O materializer gerado deve operar contra `IDataRecord` e contra contratos do FluentMap. Ele nao deve gerar chamadas para APIs internas do Dapper nem reimplementar o pipeline de consulta do Dapper. + +### Alternativas + +- Gerar codigo que chama diretamente detalhes internos do Dapper. +- Delegar toda conversao escalar ao Dapper.AOT. +- Reimplementar parser completo de linhas do Dapper. + +### Consequencias + +- O FluentMap permanece focado em object graph mapping. +- Upgrades do Dapper continuam menos arriscados. +- TypeHandler gerado fica como decisao especifica futura. +- Pode haver menor cobertura gerada ate existir uma boundary segura de conversao. + +## ADR-7.1-004 - Fallback Transparente e Diagnosticavel + +### Contexto + +Muitos maps validos para FluentMap nao sao estaticamente geraveis: assembly scanning, conventions customizadas, nomes calculados e maps construidos com estado runtime. + +### Decisao + +Ausencia de materializer gerado nao deve ser erro. O sistema deve cair para `NestedMaterializationPlan` e, em fase futura, oferecer diagnostico explicando o motivo. + +### Alternativas + +- Falhar quando o generator nao conseguir cobrir um map. +- Exigir opt-in explicito para fallback. +- Fazer o generator interpretar codigo arbitrario. + +### Consequencias + +- Consumidores atuais nao quebram. +- Cobertura gerada pode crescer incrementalmente. +- Performance/AOT precisam ser comunicados como propriedade do caminho gerado, nao da API inteira. +- Diagnostics serao importantes para evitar surpresa. + +## ADR-7.1-005 - Nao Replicar Dapper.AOT + +### Contexto + +Dapper.AOT tem escopo proprio para gerar materializacao e execucao de queries Dapper. O FluentMap tem escopo menor: traduzir metadata de FluentMap em object graphs quando o usuario usa `QueryMapped*`. + +### Decisao + +A Etapa 7 nao deve gerar SQL, commands, parametros, readers, handlers gerais de Dapper ou substitutos de `Query()`. O generated materializer do FluentMap deve se limitar a `IDataRecord -> object graph`. + +### Alternativas + +- Construir um pipeline AOT completo concorrente ao Dapper.AOT. +- Integrar diretamente com Dapper.AOT como dependencia obrigatoria. +- Abandonar `QueryMapped*` e recomendar somente Dapper.AOT. + +### Consequencias + +- Mantem a biblioteca pequena e coerente. +- Reduz risco de manutencao. +- Permite interoperar com Dapper normal. +- Nao resolve todos os cenarios AOT de Dapper, apenas o trecho FluentMap-controlled. + +## ADR-7.1-006 - Evolucao Sem Breaking Change + +### Contexto + +O FluentMap e uma biblioteca publica com APIs legadas preservadas, incluindo dicionarios mutaveis globais. O README atual documenta `QueryMapped*` como runtime reflection/dynamic-code based. + +### Decisao + +A evolucao de generated materialization deve ser aditiva. `QueryMapped*`, `AddGeneratedMappings()`, `FluentMapper.Initialize`, `Validate`, `Explain`, Dapper type maps e fallback runtime devem permanecer compativeis. + +### Alternativas + +- Fazer uma major version removendo fallback e APIs legadas. +- Tornar generator obrigatorio. +- Alterar semanticamente `QueryMapped*` para falhar fora do generated path. + +### Consequencias + +- A primeira entrega pode sair como melhoria incremental. +- O design precisa validar correspondencia entre descriptor gerado e configuracao efetiva. +- Mutable dictionaries legados continuam sendo risco arquitetural. +- Claims de AOT/performance precisam ser condicionais ate validacao. + +## ADR-7.1-007 - Primeira Cobertura Gerada Deve Priorizar Explicit Maps Literais + +### Contexto + +O analyzer e o generator atual ja conseguem reconhecer classes de map, `IEntityMap`, `IProfileMap` e parte da DSL. Conventions customizadas e naming policies dinamicas executam codigo arbitrario. + +### Decisao + +A primeira fase de geracao deve focar explicit maps com `Map(...).ToColumn("literal")`, `Ignore`, `IncludeBase` geravel e profiles tipados. + +### Alternativas + +- Incluir todas as conventions desde o inicio. +- Gerar apenas propriedades root sem profiles. +- Esperar uma interpretacao completa da DSL. + +### Consequencias + +- O escopo inicial fica testavel. +- Nested/value object pode evoluir sobre uma base confiavel. +- Naming policies built-in podem ser adicionadas depois com regras claras. +- Conventions customizadas continuam no fallback. diff --git a/.sdd/etapa-7/STATUS.md b/.sdd/etapa-7/STATUS.md new file mode 100644 index 0000000..65f878c --- /dev/null +++ b/.sdd/etapa-7/STATUS.md @@ -0,0 +1,73 @@ +# Etapa 7 Status + +## Objetivo + +Definir a arquitetura e a especificacao inicial para materializacao gerada no FluentMap, preservando o escopo da biblioteca em `IDataReader / IDataRecord -> metadata de mapping -> object graph` e mantendo fallback runtime. + +## Concluido + +- Lido `README.md`. +- Examinada a solution `Dapper.FluentMap.sln`. +- Examinados projetos core, analyzers, generators, testes, AOT smoke e runtime de materializacao. +- Executado `git status`. +- Examinados commits recentes, incluindo: + - `63effef chore(fluentmap): evaluate generated materializer architecture`; + - `dde0ee0 refactor(fluentmap): isolate dapper compatibility internals`; + - etapas anteriores de profiles, Value Objects, nested mapping e source generator. +- Confirmado que `.sdd/etapa-7/` nao existia no inicio deste prompt. +- Criada a especificacao `.sdd/etapa-7/01-generated-materialization-architecture.md`. +- Criado o registro de decisoes `.sdd/etapa-7/DECISIONS.md`. +- Executado `dotnet restore ./Dapper.FluentMap.sln`: sucesso. +- Executado `dotnet build ./Dapper.FluentMap.sln --configuration Release --no-restore`: sucesso, 0 warnings, 0 errors. +- Executado `dotnet test ./Dapper.FluentMap.sln --configuration Release --no-build`: sucesso, 231 testes aprovados. +- Revisado o diff final para limitar o commit aos documentos da Etapa 7. + +## Em andamento + +Nenhum no escopo deste prompt apos o commit local. + +## Proximos passos + +1. Criar benchmarks de baseline para Dapper default, `QueryMapped*` runtime e generated-like. +2. Definir contratos runtime minimos para descriptor, lookup e fallback. +3. Prototipar flat/simple generated materialization para explicit maps literais. +4. Expandir para nested objects, immutable objects e Value Objects. +5. Integrar generated lookup ao runtime antes do fallback. +6. Validar trimming, Native AOT e performance antes de documentar ganhos. + +## Decisoes relevantes + +- Generated materializer complementa, nao substitui, `NestedMaterializationPlan`. +- Lookup deve considerar entity, profile e column shape ordenado. +- O generated path deve operar contra `IDataRecord` e evitar acoplamento a internals do Dapper. +- Fallback runtime e obrigatorio e deve ser diagnosticavel. +- O projeto nao deve replicar Dapper.AOT. +- Evolucao deve ser aditiva e sem breaking change. +- Primeira cobertura gerada deve priorizar explicit maps com colunas literais. + +## Riscos conhecidos + +- Divergencia entre runtime materializer e generated materializer. +- Public mutable dictionaries podem invalidar descriptors gerados. +- TypeHandlers do Dapper ainda exigem boundary segura. +- Conventions customizadas e naming policies dinamicas limitam cobertura gerada. +- Ganhos de performance ainda sao hipotese ate benchmarks. +- Compatibilidade Native AOT completa ainda depende de validacao real. + +## Arquivos importantes + +- `.sdd/etapa-7/01-generated-materialization-architecture.md` +- `.sdd/etapa-7/DECISIONS.md` +- `.sdd/etapa-7/STATUS.md` +- `README.md` +- `src/Dapper.FluentMap/QueryMappedExtensions.cs` +- `src/Dapper.FluentMap/MappingRegistry.cs` +- `src/Dapper.FluentMap/Materialization/NestedMaterializationPlan.cs` +- `src/Dapper.FluentMap.Generators/MappingRegistrationGenerator.cs` +- `src/Dapper.FluentMap.Analyzers/FluentMapConfigurationAnalyzer.cs` +- `test/Dapper.FluentMap.Tests/GeneratedMaterializerSpikeTests.cs` +- `docs/sdd/etapa-6/04-generated-materializer-spike.md` + +## Ultimo prompt executado + +7.1 From 130b950ad7a95e45de51ef1a756ecd64bbd1cd89 Mon Sep 17 00:00:00 2001 From: Rodrigo de Oliveira Date: Mon, 27 Jul 2026 08:02:40 -0300 Subject: [PATCH 02/49] perf(benchmarks): establish materialization baseline --- .sdd/etapa-7/02-performance-baseline.md | 135 ++++ .sdd/etapa-7/02-performance-spec.md | 168 +++++ .sdd/etapa-7/STATUS.md | 34 +- Dapper.FluentMap.sln | 9 + .../Dapper.FluentMap.Benchmarks.csproj | 21 + .../Dapper.FluentMap.Benchmarks/Program.cs | 627 ++++++++++++++++++ 6 files changed, 988 insertions(+), 6 deletions(-) create mode 100644 .sdd/etapa-7/02-performance-baseline.md create mode 100644 .sdd/etapa-7/02-performance-spec.md create mode 100644 benchmarks/Dapper.FluentMap.Benchmarks/Dapper.FluentMap.Benchmarks.csproj create mode 100644 benchmarks/Dapper.FluentMap.Benchmarks/Program.cs diff --git a/.sdd/etapa-7/02-performance-baseline.md b/.sdd/etapa-7/02-performance-baseline.md new file mode 100644 index 0000000..194ba14 --- /dev/null +++ b/.sdd/etapa-7/02-performance-baseline.md @@ -0,0 +1,135 @@ +# Baseline de Performance + +Status: BASELINE +Prompt: 7.2 +Data: 2026-07-27 + +## Escopo + +Baseline inicial antes de generated materializers. Os benchmarks medem o comportamento atual usando: + +- Dapper puro com `Query`; +- Dapper + FluentMap root mapping via APIs normais do Dapper; +- `QueryMapped` simples; +- `QueryMapped` com constructor mapping imutavel; +- `QueryMapped` com nested object; +- `QueryMapped` com Value Object. + +Nenhuma implementacao produtiva foi alterada para melhorar resultados. + +## Ambiente + +- OS: Windows 11 `10.0.26200.8875/25H2/2025Update/HudsonValley2` +- CPU: 11th Gen Intel Core i5-1145G7 2.60GHz, 1 CPU, 8 logical cores, 4 physical cores +- .NET SDK: `10.0.302` +- Runtime: `.NET 10.0.10`, X64 RyuJIT +- GC: Concurrent Workstation +- BenchmarkDotNet: `0.15.8` +- Dapper: `2.1.79` +- Microsoft.Data.Sqlite: `10.0.10` +- SQLitePCLRaw.bundle_e_sqlite3: `2.1.12` + +## Dataset + +- SQLite em memoria. +- `BenchmarkRows` com 1000 linhas por operacao. +- Colunas: `Id`, `Name`, `Age`, `Balance`, `CreatedAt`, `City`, `PostalCode`, `Country`, `Cpf`, `Currency`. +- Tipos exercitados: `int`, `string`, `decimal`, `DateTime` e objetos compostos por construtor. +- Todos os benchmarks materializam completamente os 1000 resultados. + +## Comandos Executados + +Rodada steady state: + +```bash +dotnet run --project benchmarks/Dapper.FluentMap.Benchmarks/Dapper.FluentMap.Benchmarks.csproj --configuration Release -- --filter *MaterializationSteadyStateBenchmarks* +``` + +Rodada cold start: + +```bash +dotnet run --project benchmarks/Dapper.FluentMap.Benchmarks/Dapper.FluentMap.Benchmarks.csproj --configuration Release -- --filter *MaterializationColdStartBenchmarks* +``` + +Os artefatos do BenchmarkDotNet foram gravados em `.tmp/benchmarks/BenchmarkDotNet.Artifacts/`, caminho ignorado pelo repositorio. + +## Resultados - Steady State + +Job: `ShortRun`, `LaunchCount=1`, `WarmupCount=3`, `IterationCount=3`. + +| Method | Mean | StdDev | Ratio | Gen0 | Gen1 | Allocated | Alloc Ratio | +| --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: | +| QueryMappedValueObject | 1.337 ms | 0.0216 ms | 0.90 | 140.6250 | 37.1094 | 587.7 KB | 2.08 | +| DapperWithFluentMapRootMapping | 1.478 ms | 0.1041 ms | 0.99 | 68.3594 | - | 283.3 KB | 1.00 | +| DapperPure | 1.503 ms | 0.1435 ms | 1.01 | 68.3594 | - | 283.17 KB | 1.00 | +| QueryMappedSimple | 1.547 ms | 0.1191 ms | 1.04 | 87.8906 | 19.5313 | 361.28 KB | 1.28 | +| QueryMappedNestedObject | 1.573 ms | 0.1261 ms | 1.05 | 91.7969 | 29.2969 | 376.86 KB | 1.33 | +| QueryMappedImmutableConstructor | 1.651 ms | 0.1207 ms | 1.11 | 103.5156 | 9.7656 | 423.78 KB | 1.50 | + +### Leitura + +- As diferencas de tempo nesta rodada curta nao devem ser tratadas como ranking forte. Os intervalos de erro sao grandes em relacao as medias. +- `DapperWithFluentMapRootMapping` ficou essencialmente alinhado com Dapper puro em tempo e alocacao para root mapping. +- `QueryMapped*` mostrou alocacao maior mesmo quando o tempo ficou proximo: + - simple: cerca de 1.28x Dapper puro; + - nested: cerca de 1.33x Dapper puro; + - immutable constructor: cerca de 1.50x Dapper puro; + - Value Object: cerca de 2.08x Dapper puro. +- Em termos aproximados por linha, Dapper puro/root ficaram perto de 290 B/linha, enquanto `QueryMappedValueObject` ficou perto de 602 B/linha. + +## Resultados - Cold Start + +Job: `RunStrategy=ColdStart`, `LaunchCount=8`, `WarmupCount=0`, `IterationCount=1`. + +| Method | Mean | StdDev | Ratio | Allocated | Alloc Ratio | +| --- | ---: | ---: | ---: | ---: | ---: | +| QueryMappedNestedColdStart | 184.4 ms | 26.52 ms | 0.75 | 442.46 KB | 1.55 | +| FluentMapRootMappingColdStart | 203.5 ms | 46.85 ms | 0.82 | 364.78 KB | 1.28 | +| QueryMappedValueObjectColdStart | 226.1 ms | 48.94 ms | 0.92 | 644.65 KB | 2.25 | +| DapperPureColdStart | 250.9 ms | 37.64 ms | 1.02 | 285.95 KB | 1.00 | + +### Leitura + +- Cold start apresentou variancia alta e outliers, incluindo Dapper puro. Os tempos nao devem ser usados para afirmar que um caminho e mais rapido que outro. +- As allocations cold start reforcam a mesma direcao do steady state: `QueryMapped*` aloca mais, especialmente Value Object. +- O custo medido inclui inicializacao do provider SQLite, criacao/populacao da tabela em memoria, configuracao FluentMap quando aplicavel, primeira query Dapper e primeira criacao de plano runtime quando aplicavel. + +## Hotspots e Hipoteses + +Hotspots esperados para investigar nas etapas futuras: + +- `NestedMaterializationPlan.Create(...)` criando `DefaultTypeMap`, resolvendo maps por coluna e selando arvore de materializacao. +- `Expression.Compile()` para factories, getters, setters e construtores. +- `object[]` por chamada de construtor em `ConstructorPlan.Create(...)`. +- Conversao escalar em `ConvertValue(...)`, incluindo `Convert.ChangeType` e defaults de value types. +- Alocacao adicional de subobjetos em nested/value object, que e funcionalmente necessaria mas pode ser reduzida no caminho gerado. +- Buffering de `QueryMapped*`, que hoje retorna uma lista materializada. + +## Limitacoes + +- SQLite em memoria mede tambem provider, SQL e reader; nao isola materializacao pura. +- `ShortRun` e adequado para baseline local rapida, mas nao substitui uma rodada longa para decisao final de performance. +- Cold start em BenchmarkDotNet mede processo frio e mostrou alta variancia nesta maquina. +- Resultados locais nao sao promessa publica de performance. +- O baseline nao inclui profiles, TypeHandlers escalares ou null-heavy datasets. +- O baseline nao compara com generated materializers, porque eles ainda nao foram implementados. + +## Orcamento Inicial Refinado + +Com base nesta rodada: + +- Manter `Dapper + FluentMap root mapping` proximo de Dapper puro em tempo e alocacao para cenarios root simples. +- Tratar `QueryMappedSimple` steady state como teto inicial do fallback runtime para materializacao simples: aproximadamente `361 KB` por 1000 linhas nesta maquina. +- Para generated materializers futuros, buscar reducao mensuravel de alocacao em relacao ao fallback runtime: + - nested: baseline `376.86 KB` por 1000 linhas; + - immutable constructor: baseline `423.78 KB` por 1000 linhas; + - Value Object: baseline `587.7 KB` por 1000 linhas. +- So transformar diferenca de tempo em requisito apos rodada mais longa e estatisticamente estavel. + +## Repetir Apos Etapas Futuras + +Repetir estes benchmarks: + +- apos 7.4: `DapperPure`, `DapperWithFluentMapRootMapping`, `QueryMappedSimple` e cold root/simple; +- apos 7.5: `QueryMappedImmutableConstructor`, `QueryMappedNestedObject`, `QueryMappedValueObject` e seus equivalentes generated; +- apos 7.6: todos os steady state e cold start para validar lookup generated/fallback integrado. diff --git a/.sdd/etapa-7/02-performance-spec.md b/.sdd/etapa-7/02-performance-spec.md new file mode 100644 index 0000000..4b518a1 --- /dev/null +++ b/.sdd/etapa-7/02-performance-spec.md @@ -0,0 +1,168 @@ +# Especificacao de Performance + +Status: SPECIFICATION +Prompt: 7.2 +Data: 2026-07-27 + +## Objetivo + +Estabelecer uma baseline reproduzivel para materializacao no FluentMap antes de qualquer generated materializer. Esta baseline mede o comportamento atual e servira como referencia para validar as etapas 7.4, 7.5 e 7.6. + +O foco da medicao e: + +```text +Dapper ExecuteReader / Query -> IDataReader -> object materialization +``` + +Nao ha objetivo de otimizar a implementacao produtiva neste prompt. + +## Artefatos Validados + +- `README.md` documenta `QueryMapped*` como caminho opt-in para nested objects, immutable types, Value Objects e profiles. +- `.sdd/etapa-7/STATUS.md` registra 7.1 como ultimo prompt executado. +- `.sdd/etapa-7/01-generated-materialization-architecture.md` define generated materializer como complemento futuro do runtime materializer. +- `.sdd/etapa-7/DECISIONS.md` registra fallback runtime obrigatorio, lookup por entity/profile/column shape e nao acoplamento a internals do Dapper. +- O codigo atual confirma que `QueryMapped*` ainda usa `SqlMapper.ExecuteReader`, coleta nomes de colunas e usa `NestedMaterializationPlan`. + +Nao foi encontrada divergencia funcional entre a documentacao SDD lida e o codigo examinado neste prompt. O generated materializer ainda nao existe, como esperado. + +## Cenarios + +Os benchmarks devem cobrir pelo menos: + +1. Dapper puro + - `connection.Query()` sem FluentMap registrado. + - Entidade mutavel com nomes de colunas iguais aos membros. +2. Dapper + FluentMap root mapping + - `connection.Query()` com `FluentMapper.Initialize(...)` e explicit maps root-level. + - Mede o custo do type map FluentMap instalado no Dapper sem `QueryMapped*`. +3. `QueryMapped` simples + - Entidade mutavel root-level usando explicit maps. + - Mede o caminho FluentMap-controlled mesmo sem nested object. +4. Constructor mapping imutavel + - Entidade imutavel com construtor publico e explicit maps root-level. + - Comparar Dapper + FluentMap constructor mapping e `QueryMapped`. +5. Nested object mapping + - Entidade com objeto aninhado mutavel e ao menos um caminho `Map(x => x.Address.City)`. + - Mede criacao de subarvore, null semantics e setters compilados. +6. Value object mapping + - Entidade imutavel com Value Object construido por construtor publico a partir de coluna componente. + - Mede binding bottom-up e custo de construtor/conversao. + +## Dataset + +Dataset inicial: + +- SQLite em memoria, aberto por benchmark class. +- Uma tabela por familia de entidade para evitar SQL dinamico complexo. +- `RowCount = 1000` por operacao steady state. +- Colunas entre 2 e 6 por linha, suficientes para exercitar: + - `int`; + - `long`; + - `string`; + - `decimal`; + - `DateTime`; + - valores `NULL` em cenarios nested/value object dedicados quando necessario. + +O numero de linhas deve ser grande o suficiente para reduzir ruido fixo de chamada e pequeno o suficiente para permitir repeticao local. + +## Warmup + +Para steady state: + +- Inicializar FluentMap uma vez em `GlobalSetup`. +- Criar tabelas e popular dados em `GlobalSetup`. +- Executar uma consulta por cenario em `GlobalSetup` para aquecer: + - cache do Dapper; + - type map do Dapper; + - cache de `NestedMaterializationPlan`; + - delegates compilados com expression trees; + - JIT do hot path. + +Para cold start: + +- Isolar em benchmark class separada. +- Medir uma primeira query em tipos dedicados para evitar cache anterior de Dapper/FluentMap. +- Quando o cenario exigir configuracao, resetar FluentMap e registrar o map dentro da operacao medida. +- Manter o mesmo SQL e dataset, mas interpretar o resultado como custo combinado de configuracao + primeira query. + +Cold start nao deve ser comparado diretamente com steady state como se medisse throughput por linha. + +## Metricas + +Usar BenchmarkDotNet com: + +- media e dispersao por operacao; +- Gen0/Gen1/Gen2 quando disponivel; +- allocated bytes por operacao; +- runtime e job registrados no relatorio; +- versoes de dependencias registradas no documento de baseline. + +Metricas principais: + +- tempo total para materializar `RowCount` linhas; +- bytes alocados por operacao; +- alocacao aproximada por linha; +- relacao entre Dapper puro, Dapper + FluentMap root e `QueryMapped*` runtime. + +Metricas secundarias: + +- custo de primeira consulta/configuracao; +- diferenca entre root simple, immutable, nested e Value Object; +- estabilidade entre rodadas. + +## Comparacao Justa + +Regras: + +- Usar o mesmo provider SQLite em memoria para todos os cenarios. +- Materializar completamente o resultado em lista/array em todos os metodos. +- Evitar que deferred execution fique fora da medicao. +- Usar SQL equivalente e o mesmo numero de linhas por cenario. +- Separar Dapper normal de `QueryMapped*`, porque `QueryMapped*` e bufferizado por contrato atual. +- Nao usar APIs internas do Dapper. +- Nao alterar codigo produtivo para favorecer benchmark. +- Nao comparar nested/value object com Dapper puro como equivalentes funcionais quando Dapper puro nao materializa o mesmo grafo. + +## Reflection, Expression Trees e Caches + +Pontos esperados de custo no codigo atual: + +- `DefaultTypeMap` criado durante criacao do plano runtime. +- `registry.GetProfilePropertyMap(...)` consultado por coluna. +- `MaterializationPlanCacheKey` por entity/profile/shape ordenado. +- `NestedMaterializationPlan.Create(...)` monta arvore de materializacao. +- `Expression.Compile()` cria factories, getters, setters, constructors e TypeHandler adapters. +- `Convert.ChangeType`, enum/Guid handling e `Activator.CreateInstance` participam da conversao escalar. + +Steady state deve aquecer esses custos para medir o hot path atual. Cold start deve evidencia-los como custo inicial. + +## Orcamento Inicial de Performance + +Este prompt nao define numeros absolutos como requisito. O orcamento inicial e qualitativo e sera refinado com a baseline medida: + +- `Dapper + FluentMap root mapping` deve permanecer proximo do Dapper puro para formas root simples, porque usa o pipeline normal do Dapper. +- `QueryMapped` simples pode ser mais caro que Dapper puro, mas seu overhead deve ser entendido e tratado como teto para o caminho gerado futuro. +- Nested object e Value Object devem ser comparados principalmente contra o proprio `QueryMapped*` runtime atual, porque Dapper puro nao entrega o mesmo comportamento sem codigo adicional do usuario. +- Generated materializers futuros devem buscar reduzir alocacoes e tempo do hot path de `QueryMapped*`, especialmente nos cenarios nested, immutable e Value Object. +- Regressao futura relevante deve ser investigada quando ultrapassar o ruido estatistico da baseline e afetar um cenario funcionalmente equivalente. + +Depois desta baseline, as etapas futuras devem transformar essas expectativas em limites revisaveis baseados em dados reais. + +## Riscos de Benchmarks Artificiais + +- SQLite em memoria mede tambem parser/executor SQL e provider, nao apenas materializacao. +- Queries `SELECT` simples podem favorecer cache interno do provider. +- Linhas muito pequenas escondem custo de conversao; linhas muito grandes escondem custo fixo de plano. +- Dados deterministicos podem nao expor todos os caminhos de null/default/conversao. +- Cold start em processo unico nao zera todos os caches estaticos do Dapper. +- BenchmarkDotNet pode produzir resultados diferentes conforme CPU scaling, antivirus, carga da maquina e modo de energia. +- Resultados de uma maquina local nao devem ser usados como promessa publica de performance. + +## Repeticoes Futuras + +Repetir a rodada representativa: + +- apos 7.4, para comparar flat/simple generated materialization contra `QueryMapped` simples; +- apos 7.5, para comparar nested, immutable e Value Object gerados contra runtime; +- apos 7.6, para validar lookup generated/fallback integrado e diagnosticos. diff --git a/.sdd/etapa-7/STATUS.md b/.sdd/etapa-7/STATUS.md index 65f878c..f987e32 100644 --- a/.sdd/etapa-7/STATUS.md +++ b/.sdd/etapa-7/STATUS.md @@ -21,6 +21,22 @@ Definir a arquitetura e a especificacao inicial para materializacao gerada no Fl - Executado `dotnet build ./Dapper.FluentMap.sln --configuration Release --no-restore`: sucesso, 0 warnings, 0 errors. - Executado `dotnet test ./Dapper.FluentMap.sln --configuration Release --no-build`: sucesso, 231 testes aprovados. - Revisado o diff final para limitar o commit aos documentos da Etapa 7. +- Criada a especificacao `.sdd/etapa-7/02-performance-spec.md`. +- Adicionado projeto `benchmarks/Dapper.FluentMap.Benchmarks/` com BenchmarkDotNet isolado dos testes normais. +- Adicionado benchmark steady state para: + - Dapper puro; + - Dapper + FluentMap root mapping; + - `QueryMapped` simples; + - immutable constructor mapping; + - nested object mapping; + - Value Object mapping. +- Adicionado benchmark cold start para Dapper puro, FluentMap root mapping, nested e Value Object. +- Executada rodada steady state representativa com `MaterializationSteadyStateBenchmarks`: sucesso. +- Executada rodada cold start representativa com `MaterializationColdStartBenchmarks`: sucesso. +- Criado baseline `.sdd/etapa-7/02-performance-baseline.md`. +- Executado `dotnet restore ./Dapper.FluentMap.sln`: sucesso. +- Executado `dotnet build ./Dapper.FluentMap.sln --configuration Release --no-restore`: sucesso, 0 warnings, 0 errors. +- Executado `dotnet test ./Dapper.FluentMap.sln --configuration Release --no-build`: sucesso, 231 testes aprovados. ## Em andamento @@ -28,12 +44,14 @@ Nenhum no escopo deste prompt apos o commit local. ## Proximos passos -1. Criar benchmarks de baseline para Dapper default, `QueryMapped*` runtime e generated-like. -2. Definir contratos runtime minimos para descriptor, lookup e fallback. -3. Prototipar flat/simple generated materialization para explicit maps literais. +1. Definir contratos runtime minimos para descriptor, lookup e fallback. +2. Prototipar flat/simple generated materialization para explicit maps literais. +3. Repetir benchmarks root/simple apos 7.4. 4. Expandir para nested objects, immutable objects e Value Objects. -5. Integrar generated lookup ao runtime antes do fallback. -6. Validar trimming, Native AOT e performance antes de documentar ganhos. +5. Repetir benchmarks nested, immutable e Value Object apos 7.5. +6. Integrar generated lookup ao runtime antes do fallback. +7. Repetir todos os benchmarks apos 7.6 para validar lookup generated/fallback integrado. +8. Validar trimming, Native AOT e performance antes de documentar ganhos. ## Decisoes relevantes @@ -63,11 +81,15 @@ Nenhum no escopo deste prompt apos o commit local. - `src/Dapper.FluentMap/QueryMappedExtensions.cs` - `src/Dapper.FluentMap/MappingRegistry.cs` - `src/Dapper.FluentMap/Materialization/NestedMaterializationPlan.cs` +- `benchmarks/Dapper.FluentMap.Benchmarks/Program.cs` +- `benchmarks/Dapper.FluentMap.Benchmarks/Dapper.FluentMap.Benchmarks.csproj` - `src/Dapper.FluentMap.Generators/MappingRegistrationGenerator.cs` - `src/Dapper.FluentMap.Analyzers/FluentMapConfigurationAnalyzer.cs` - `test/Dapper.FluentMap.Tests/GeneratedMaterializerSpikeTests.cs` - `docs/sdd/etapa-6/04-generated-materializer-spike.md` +- `.sdd/etapa-7/02-performance-spec.md` +- `.sdd/etapa-7/02-performance-baseline.md` ## Ultimo prompt executado -7.1 +7.2 diff --git a/Dapper.FluentMap.sln b/Dapper.FluentMap.sln index 915e4e9..828780f 100644 --- a/Dapper.FluentMap.sln +++ b/Dapper.FluentMap.sln @@ -31,6 +31,10 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Dapper.FluentMap.Generators 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 +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "benchmarks", "benchmarks", "{66320409-64EC-F7C5-3DEF-65E7510DAAD1}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Dapper.FluentMap.Benchmarks", "benchmarks\Dapper.FluentMap.Benchmarks\Dapper.FluentMap.Benchmarks.csproj", "{B09CFDAC-19CB-48F2-B7F7-03A47430C707}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -77,6 +81,10 @@ Global {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 + {B09CFDAC-19CB-48F2-B7F7-03A47430C707}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {B09CFDAC-19CB-48F2-B7F7-03A47430C707}.Debug|Any CPU.Build.0 = Debug|Any CPU + {B09CFDAC-19CB-48F2-B7F7-03A47430C707}.Release|Any CPU.ActiveCfg = Release|Any CPU + {B09CFDAC-19CB-48F2-B7F7-03A47430C707}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -92,6 +100,7 @@ Global {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} + {B09CFDAC-19CB-48F2-B7F7-03A47430C707} = {66320409-64EC-F7C5-3DEF-65E7510DAAD1} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {10834736-59FD-47FF-9344-096247DC48CD} diff --git a/benchmarks/Dapper.FluentMap.Benchmarks/Dapper.FluentMap.Benchmarks.csproj b/benchmarks/Dapper.FluentMap.Benchmarks/Dapper.FluentMap.Benchmarks.csproj new file mode 100644 index 0000000..6276a74 --- /dev/null +++ b/benchmarks/Dapper.FluentMap.Benchmarks/Dapper.FluentMap.Benchmarks.csproj @@ -0,0 +1,21 @@ + + + + + + + + Exe + net10.0 + false + enable + enable + + + + + + + + + diff --git a/benchmarks/Dapper.FluentMap.Benchmarks/Program.cs b/benchmarks/Dapper.FluentMap.Benchmarks/Program.cs new file mode 100644 index 0000000..fae5a49 --- /dev/null +++ b/benchmarks/Dapper.FluentMap.Benchmarks/Program.cs @@ -0,0 +1,627 @@ +using System.Data; +using BenchmarkDotNet.Attributes; +using BenchmarkDotNet.Configs; +using BenchmarkDotNet.Engines; +using BenchmarkDotNet.Order; +using BenchmarkDotNet.Running; +using Dapper; +using Dapper.FluentMap.Mapping; +using Microsoft.Data.Sqlite; + +namespace Dapper.FluentMap.Benchmarks; + +public static class Program +{ + public static void Main(string[] args) + { + var config = DefaultConfig.Instance + .WithArtifactsPath(Path.Combine(".tmp", "benchmarks", "BenchmarkDotNet.Artifacts")); + + BenchmarkSwitcher.FromAssembly(typeof(Program).Assembly).Run(args, config); + } +} + +[MemoryDiagnoser] +[ShortRunJob] +[Orderer(SummaryOrderPolicy.FastestToSlowest)] +public class MaterializationSteadyStateBenchmarks +{ + private const int RowCount = 1000; + + private SqliteConnection _connection = null!; + + [GlobalSetup] + public void GlobalSetup() + { + SQLitePCL.Batteries_V2.Init(); + ResetPublicFluentState(); + + FluentMapper.Initialize(configuration => + { + configuration.AddMap(new RootMappedCustomerMap()); + configuration.AddMap(new QueryMappedSimpleCustomerMap()); + configuration.AddMap(new ImmutableCustomerMap()); + configuration.AddMap(new NestedCustomerMap()); + configuration.AddMap(new ValueObjectCustomerMap()); + }); + + _connection = OpenPopulatedConnection(); + + DapperPure(); + DapperWithFluentMapRootMapping(); + QueryMappedSimple(); + QueryMappedImmutableConstructor(); + QueryMappedNestedObject(); + QueryMappedValueObject(); + } + + [GlobalCleanup] + public void GlobalCleanup() + { + _connection.Dispose(); + ResetPublicFluentState(); + } + + [Benchmark(Baseline = true)] + public int DapperPure() + { + return _connection.Query( + "SELECT Id, Name, Age, Balance, CreatedAt FROM BenchmarkRows;") + .AsList() + .Count; + } + + [Benchmark] + public int DapperWithFluentMapRootMapping() + { + return _connection.Query( + "SELECT Id AS person_id, Name AS full_name, Age AS customer_age, Balance AS account_balance, CreatedAt AS created_at FROM BenchmarkRows;") + .AsList() + .Count; + } + + [Benchmark] + public int QueryMappedSimple() + { + return _connection.QueryMapped( + "SELECT Id AS customer_id, Name AS full_name, Age AS customer_age, Balance AS account_balance, CreatedAt AS created_at FROM BenchmarkRows;") + .Count(); + } + + [Benchmark] + public int QueryMappedImmutableConstructor() + { + return _connection.QueryMapped( + "SELECT Id AS customer_id, Name AS full_name, Age AS customer_age, Balance AS account_balance, CreatedAt AS created_at FROM BenchmarkRows;") + .Count(); + } + + [Benchmark] + public int QueryMappedNestedObject() + { + return _connection.QueryMapped( + "SELECT Id AS customer_id, Name AS full_name, City AS city, PostalCode AS postal_code, Country AS country FROM BenchmarkRows;") + .Count(); + } + + [Benchmark] + public int QueryMappedValueObject() + { + return _connection.QueryMapped( + "SELECT Id AS customer_id, Cpf AS cpf, Balance AS amount, Currency AS currency FROM BenchmarkRows;") + .Count(); + } + + private static SqliteConnection OpenPopulatedConnection() + { + var connection = new SqliteConnection("Data Source=:memory:"); + connection.Open(); + CreateRows(connection); + return connection; + } + + private static void CreateRows(IDbConnection connection) + { + connection.Execute( + @"CREATE TABLE BenchmarkRows ( + Id INTEGER NOT NULL, + Name TEXT NOT NULL, + Age INTEGER NOT NULL, + Balance REAL NOT NULL, + CreatedAt TEXT NOT NULL, + City TEXT NOT NULL, + PostalCode TEXT NOT NULL, + Country TEXT NOT NULL, + Cpf TEXT NOT NULL, + Currency TEXT NOT NULL + );"); + + connection.Execute( + @"WITH RECURSIVE numbers(Value) AS ( + SELECT 1 + UNION ALL + SELECT Value + 1 FROM numbers WHERE Value < @RowCount + ) + INSERT INTO BenchmarkRows ( + Id, + Name, + Age, + Balance, + CreatedAt, + City, + PostalCode, + Country, + Cpf, + Currency + ) + SELECT + Value, + 'Customer ' || Value, + 18 + (Value % 50), + 1000.25 + Value, + strftime('%Y-%m-%dT%H:%M:%f', '2020-01-01', '+' || Value || ' minutes'), + 'City ' || (Value % 17), + printf('%05d', Value), + 'BR', + printf('%011d', Value), + 'BRL' + FROM numbers;", + new { RowCount }); + } + + private static void ResetPublicFluentState() + { + FluentMapper.EntityMaps.Clear(); + FluentMapper.TypeConventions.Clear(); + + foreach (var type in BenchmarkTypes.AllMappedTypes) + { + SqlMapper.SetTypeMap(type, null); + } + } +} + +[MemoryDiagnoser] +[SimpleJob(RunStrategy.ColdStart, launchCount: 8, warmupCount: 0, iterationCount: 1)] +[Orderer(SummaryOrderPolicy.FastestToSlowest)] +public class MaterializationColdStartBenchmarks +{ + [Benchmark(Baseline = true)] + public int DapperPureColdStart() + { + SQLitePCL.Batteries_V2.Init(); + + using var connection = MaterializationBenchmarkDatabase.OpenPopulatedConnection(); + return connection.Query( + "SELECT Id, Name, Age, Balance, CreatedAt FROM BenchmarkRows;") + .AsList() + .Count; + } + + [Benchmark] + public int FluentMapRootMappingColdStart() + { + SQLitePCL.Batteries_V2.Init(); + ResetColdPublicState(); + + FluentMapper.Initialize(configuration => configuration.AddMap(new ColdRootMappedCustomerMap())); + + using var connection = MaterializationBenchmarkDatabase.OpenPopulatedConnection(); + return connection.Query( + "SELECT Id AS person_id, Name AS full_name, Age AS customer_age, Balance AS account_balance, CreatedAt AS created_at FROM BenchmarkRows;") + .AsList() + .Count; + } + + [Benchmark] + public int QueryMappedNestedColdStart() + { + SQLitePCL.Batteries_V2.Init(); + ResetColdPublicState(); + + FluentMapper.Initialize(configuration => configuration.AddMap(new ColdNestedCustomerMap())); + + using var connection = MaterializationBenchmarkDatabase.OpenPopulatedConnection(); + return connection.QueryMapped( + "SELECT Id AS customer_id, Name AS full_name, City AS city, PostalCode AS postal_code, Country AS country FROM BenchmarkRows;") + .Count(); + } + + [Benchmark] + public int QueryMappedValueObjectColdStart() + { + SQLitePCL.Batteries_V2.Init(); + ResetColdPublicState(); + + FluentMapper.Initialize(configuration => configuration.AddMap(new ColdValueObjectCustomerMap())); + + using var connection = MaterializationBenchmarkDatabase.OpenPopulatedConnection(); + return connection.QueryMapped( + "SELECT Id AS customer_id, Cpf AS cpf, Balance AS amount, Currency AS currency FROM BenchmarkRows;") + .Count(); + } + + private static void ResetColdPublicState() + { + FluentMapper.EntityMaps.Clear(); + FluentMapper.TypeConventions.Clear(); + + foreach (var type in BenchmarkTypes.AllColdTypes) + { + SqlMapper.SetTypeMap(type, null); + } + } +} + +internal static class MaterializationBenchmarkDatabase +{ + internal static SqliteConnection OpenPopulatedConnection() + { + var connection = new SqliteConnection("Data Source=:memory:"); + connection.Open(); + CreateRows(connection); + return connection; + } + + private static void CreateRows(IDbConnection connection) + { + connection.Execute( + @"CREATE TABLE BenchmarkRows ( + Id INTEGER NOT NULL, + Name TEXT NOT NULL, + Age INTEGER NOT NULL, + Balance REAL NOT NULL, + CreatedAt TEXT NOT NULL, + City TEXT NOT NULL, + PostalCode TEXT NOT NULL, + Country TEXT NOT NULL, + Cpf TEXT NOT NULL, + Currency TEXT NOT NULL + );"); + + connection.Execute( + @"WITH RECURSIVE numbers(Value) AS ( + SELECT 1 + UNION ALL + SELECT Value + 1 FROM numbers WHERE Value < 1000 + ) + INSERT INTO BenchmarkRows ( + Id, + Name, + Age, + Balance, + CreatedAt, + City, + PostalCode, + Country, + Cpf, + Currency + ) + SELECT + Value, + 'Customer ' || Value, + 18 + (Value % 50), + 1000.25 + Value, + strftime('%Y-%m-%dT%H:%M:%f', '2020-01-01', '+' || Value || ' minutes'), + 'City ' || (Value % 17), + printf('%05d', Value), + 'BR', + printf('%011d', Value), + 'BRL' + FROM numbers;"); + } +} + +internal static class BenchmarkTypes +{ + internal static readonly Type[] AllMappedTypes = + { + typeof(RootMappedCustomer), + typeof(QueryMappedSimpleCustomer), + typeof(ImmutableCustomer), + typeof(NestedCustomer), + typeof(ValueObjectCustomer) + }; + + internal static readonly Type[] AllColdTypes = + { + typeof(ColdRootMappedCustomer), + typeof(ColdNestedCustomer), + typeof(ColdValueObjectCustomer) + }; +} + +public sealed class PureCustomer +{ + public int Id { get; set; } + + public string Name { get; set; } = string.Empty; + + public int Age { get; set; } + + public decimal Balance { get; set; } + + public DateTime CreatedAt { get; set; } +} + +public sealed class RootMappedCustomer +{ + public int Id { get; set; } + + public string FullName { get; set; } = string.Empty; + + public int Age { get; set; } + + public decimal Balance { get; set; } + + public DateTime CreatedAt { get; set; } +} + +public sealed class RootMappedCustomerMap : EntityMap +{ + public RootMappedCustomerMap() + { + Map(customer => customer.Id).ToColumn("person_id"); + Map(customer => customer.FullName).ToColumn("full_name"); + Map(customer => customer.Age).ToColumn("customer_age"); + Map(customer => customer.Balance).ToColumn("account_balance"); + Map(customer => customer.CreatedAt).ToColumn("created_at"); + } +} + +public sealed class QueryMappedSimpleCustomer +{ + public int Id { get; set; } + + public string FullName { get; set; } = string.Empty; + + public int Age { get; set; } + + public decimal Balance { get; set; } + + public DateTime CreatedAt { get; set; } +} + +public sealed class QueryMappedSimpleCustomerMap : EntityMap +{ + public QueryMappedSimpleCustomerMap() + { + Map(customer => customer.Id).ToColumn("customer_id"); + Map(customer => customer.FullName).ToColumn("full_name"); + Map(customer => customer.Age).ToColumn("customer_age"); + Map(customer => customer.Balance).ToColumn("account_balance"); + Map(customer => customer.CreatedAt).ToColumn("created_at"); + } +} + +public sealed class ImmutableCustomer +{ + public ImmutableCustomer(int id, string fullName, int age, decimal balance, DateTime createdAt) + { + Id = id; + FullName = fullName; + Age = age; + Balance = balance; + CreatedAt = createdAt; + } + + public int Id { get; } + + public string FullName { get; } + + public int Age { get; } + + public decimal Balance { get; } + + public DateTime CreatedAt { get; } +} + +public sealed class ImmutableCustomerMap : EntityMap +{ + public ImmutableCustomerMap() + { + Map(customer => customer.Id).ToColumn("customer_id"); + Map(customer => customer.FullName).ToColumn("full_name"); + Map(customer => customer.Age).ToColumn("customer_age"); + Map(customer => customer.Balance).ToColumn("account_balance"); + Map(customer => customer.CreatedAt).ToColumn("created_at"); + } +} + +public sealed class NestedCustomer +{ + public int Id { get; set; } + + public string FullName { get; set; } = string.Empty; + + public NestedAddress Address { get; set; } = null!; +} + +public sealed class NestedAddress +{ + public string City { get; set; } = string.Empty; + + public string PostalCode { get; set; } = string.Empty; + + public string Country { get; set; } = string.Empty; +} + +public sealed class NestedCustomerMap : EntityMap +{ + public NestedCustomerMap() + { + Map(customer => customer.Id).ToColumn("customer_id"); + Map(customer => customer.FullName).ToColumn("full_name"); + Map(customer => customer.Address.City).ToColumn("city"); + Map(customer => customer.Address.PostalCode).ToColumn("postal_code"); + Map(customer => customer.Address.Country).ToColumn("country"); + } +} + +public sealed class ValueObjectCustomer +{ + public ValueObjectCustomer(int id, BenchmarkCpf cpf, BenchmarkMoney balance) + { + Id = id; + Cpf = cpf; + Balance = balance; + } + + public int Id { get; } + + public BenchmarkCpf Cpf { get; } + + public BenchmarkMoney Balance { get; } +} + +public sealed class BenchmarkCpf +{ + public BenchmarkCpf(string number) + { + Number = number; + } + + public string Number { get; } +} + +public sealed class BenchmarkMoney +{ + public BenchmarkMoney(decimal amount, string currency) + { + Amount = amount; + Currency = currency; + } + + public decimal Amount { get; } + + public string Currency { get; } +} + +public sealed class ValueObjectCustomerMap : EntityMap +{ + public ValueObjectCustomerMap() + { + Map(customer => customer.Id).ToColumn("customer_id"); + Map(customer => customer.Cpf.Number).ToColumn("cpf"); + Map(customer => customer.Balance.Amount).ToColumn("amount"); + Map(customer => customer.Balance.Currency).ToColumn("currency"); + } +} + +public sealed class ColdPureCustomer +{ + public int Id { get; set; } + + public string Name { get; set; } = string.Empty; + + public int Age { get; set; } + + public decimal Balance { get; set; } + + public DateTime CreatedAt { get; set; } +} + +public sealed class ColdRootMappedCustomer +{ + public int Id { get; set; } + + public string FullName { get; set; } = string.Empty; + + public int Age { get; set; } + + public decimal Balance { get; set; } + + public DateTime CreatedAt { get; set; } +} + +public sealed class ColdRootMappedCustomerMap : EntityMap +{ + public ColdRootMappedCustomerMap() + { + Map(customer => customer.Id).ToColumn("person_id"); + Map(customer => customer.FullName).ToColumn("full_name"); + Map(customer => customer.Age).ToColumn("customer_age"); + Map(customer => customer.Balance).ToColumn("account_balance"); + Map(customer => customer.CreatedAt).ToColumn("created_at"); + } +} + +public sealed class ColdNestedCustomer +{ + public int Id { get; set; } + + public string FullName { get; set; } = string.Empty; + + public ColdNestedAddress Address { get; set; } = null!; +} + +public sealed class ColdNestedAddress +{ + public string City { get; set; } = string.Empty; + + public string PostalCode { get; set; } = string.Empty; + + public string Country { get; set; } = string.Empty; +} + +public sealed class ColdNestedCustomerMap : EntityMap +{ + public ColdNestedCustomerMap() + { + Map(customer => customer.Id).ToColumn("customer_id"); + Map(customer => customer.FullName).ToColumn("full_name"); + Map(customer => customer.Address.City).ToColumn("city"); + Map(customer => customer.Address.PostalCode).ToColumn("postal_code"); + Map(customer => customer.Address.Country).ToColumn("country"); + } +} + +public sealed class ColdValueObjectCustomer +{ + public ColdValueObjectCustomer(int id, ColdBenchmarkCpf cpf, ColdBenchmarkMoney balance) + { + Id = id; + Cpf = cpf; + Balance = balance; + } + + public int Id { get; } + + public ColdBenchmarkCpf Cpf { get; } + + public ColdBenchmarkMoney Balance { get; } +} + +public sealed class ColdBenchmarkCpf +{ + public ColdBenchmarkCpf(string number) + { + Number = number; + } + + public string Number { get; } +} + +public sealed class ColdBenchmarkMoney +{ + public ColdBenchmarkMoney(decimal amount, string currency) + { + Amount = amount; + Currency = currency; + } + + public decimal Amount { get; } + + public string Currency { get; } +} + +public sealed class ColdValueObjectCustomerMap : EntityMap +{ + public ColdValueObjectCustomerMap() + { + Map(customer => customer.Id).ToColumn("customer_id"); + Map(customer => customer.Cpf.Number).ToColumn("cpf"); + Map(customer => customer.Balance.Amount).ToColumn("amount"); + Map(customer => customer.Balance.Currency).ToColumn("currency"); + } +} From ebfde92348d3d0fdf9f64d24845ba6d38a7c9485 Mon Sep 17 00:00:00 2001 From: Rodrigo de Oliveira Date: Tue, 28 Jul 2026 09:30:44 -0300 Subject: [PATCH 03/49] feat(materialization): introduce generated materializer contracts --- .../03-generated-materializer-contracts.md | 231 +++++++++++ .sdd/etapa-7/DECISIONS.md | 82 ++++ .sdd/etapa-7/STATUS.md | 36 +- README.md | 4 + .../Configuration/FluentMapConfiguration.cs | 52 +++ src/Dapper.FluentMap/MappingRegistry.cs | 160 +++++++- .../GeneratedMaterializerColumn.cs | 63 +++ .../GeneratedMaterializerDescriptor.cs | 90 ++++ .../GeneratedRowMaterializer.cs | 13 + src/Dapper.FluentMap/QueryMappedExtensions.cs | 18 +- .../GeneratedMaterializerContractTests.cs | 385 ++++++++++++++++++ 11 files changed, 1125 insertions(+), 9 deletions(-) create mode 100644 .sdd/etapa-7/03-generated-materializer-contracts.md create mode 100644 src/Dapper.FluentMap/Materialization/GeneratedMaterializerColumn.cs create mode 100644 src/Dapper.FluentMap/Materialization/GeneratedMaterializerDescriptor.cs create mode 100644 src/Dapper.FluentMap/Materialization/GeneratedRowMaterializer.cs create mode 100644 test/Dapper.FluentMap.Tests/GeneratedMaterializerContractTests.cs diff --git a/.sdd/etapa-7/03-generated-materializer-contracts.md b/.sdd/etapa-7/03-generated-materializer-contracts.md new file mode 100644 index 0000000..2e8f0b7 --- /dev/null +++ b/.sdd/etapa-7/03-generated-materializer-contracts.md @@ -0,0 +1,231 @@ +# Contratos para Generated Materializers + +Status: SPECIFICATION + IMPLEMENTATION +Prompt: 7.3 +Data: 2026-07-28 + +## Objetivo + +Criar a infraestrutura minima para que codigo gerado possa fornecer materializadores de linhas ao runtime do FluentMap sem acoplar o core a uma implementacao especifica do generator. + +Esta entrega nao implementa nested generated materialization nem altera o generator existente para emitir materializers. + +## Discovery + +### Entrada esperada + +O materializer gerado recebe um `IDataRecord` ja posicionado na linha corrente. + +O runtime continua responsavel por executar a query via Dapper, obter o `IDataReader`, ler os nomes das colunas, iterar as linhas e escolher generated materializer ou fallback runtime. + +O materializer gerado nao abre conexao, nao cria command, nao executa SQL e nao acessa APIs internas do Dapper. + +### Retorno + +O retorno e a entidade materializada: + +```csharp +public delegate TEntity GeneratedRowMaterializer(IDataRecord record) + where TEntity : class; +``` + +O registry interno armazena o delegate como `Func` apenas para lookup uniforme. + +### Acesso as colunas e ordinal lookup + +O contrato publico representa o shape ordenado com `GeneratedMaterializerColumn`. + +Cada coluna contem: + +- `ColumnName`: nome esperado no ordinal; +- `MemberPath`: caminho de membro esperado para colunas materializadas; +- `Ignored`: indica que a configuracao efetiva deve ignorar a coluna. + +O ordinal e implicito pela posicao da coluna no descriptor. Isso evita lookup por nome no hot path gerado e preserva a decisao de localizar por shape ordenado. + +### Null handling + +Null semantics ainda pertencem ao materializer que sera gerado em etapas futuras. O contrato atual apenas transporta o delegate. + +O generated code futuro deve preservar: + +- `DBNull` em reference/nullable vira `null`; +- `DBNull` em value type nao anulavel segue o default runtime atual; +- subarvore nested toda `NULL` nao cria objeto; +- subarvore parcialmente preenchida cria objeto; +- falhas de construtor devem ser encapsuladas com contexto quando o generator implementar esse caminho. + +### Profile identification + +Profiles sao identificados por `Type` no descriptor e por generic helper no registro: + +```csharp +configuration.AddGeneratedMaterializer(columns, materializer); +``` + +O lookup usa: + +```text +EntityType + ProfileType opcional + ordered ColumnShape +``` + +Para profiles, o runtime exige que o profile map esteja registrado. Um generated descriptor nao pode criar um profile implicito nem vazar para `SqlMapper.SetTypeMap`. + +### Constructors + +Esta entrega nao escolhe construtores gerados. O contrato deixa essa responsabilidade para o generator futuro. O runtime apenas valida se o descriptor registrado continua compativel com a configuracao efetiva por coluna/member path antes de usar o delegate. + +### Exceptions + +O contrato de registro rejeita: + +- descriptor nulo; +- delegate nulo; +- lista de colunas nula ou vazia; +- coluna nula; +- nome de coluna nulo/vazio/whitespace; +- member path nulo/vazio/whitespace para coluna materializada; +- profile type que nao implementa `IMappingProfile`; +- registro duplicado para mesma entidade/profile/shape. + +Durante lookup, profile ausente continua gerando `FluentMapConfigurationException`, como o fallback runtime ja fazia. + +### Caches e registry + +Foi adicionada uma camada interna de registry: + +```text +Generated materializer registry + key: EntityType + ProfileType + ordered ColumnShape + value: descriptor + delegate + +Runtime materialization plan cache + key: EntityType + ProfileType + ordered ColumnShape + value: NestedMaterializationPlan +``` + +O lookup generated acontece antes de criar `NestedMaterializationPlan`. + +O registry de generated materializers e limpo por `FluentMapper.Reset(...)`. Registro de maps/conventions invalida caches runtime existentes, mas nao remove descriptors gerados; o descriptor so e usado se ainda corresponder a configuracao efetiva no momento do lookup. + +### Fallback runtime + +Fallback e obrigatorio. + +O runtime cai para `NestedMaterializationPlan` quando: + +- nao ha descriptor para entity/profile/shape; +- o descriptor existe, mas seus member paths nao correspondem a configuracao efetiva; +- o descriptor espera coluna ignorada e a configuracao efetiva nao ignora; +- o descriptor espera coluna materializada e a configuracao efetiva ignora; +- a coluna nao corresponde nem a FluentMap/convention/profile nem ao fallback default do Dapper. + +## Contrato Escolhido + +Foram adicionados estes contratos publicos no namespace `Dapper.FluentMap.Materialization`: + +- `GeneratedRowMaterializer`; +- `GeneratedMaterializerColumn`; +- `GeneratedMaterializerDescriptor`. + +Foram adicionadas APIs publicas em `FluentMapConfiguration`: + +- `AddGeneratedMaterializer(IEnumerable, GeneratedRowMaterializer)`; +- `AddGeneratedMaterializer(IEnumerable, GeneratedRowMaterializer)`; +- `AddGeneratedMaterializer(GeneratedMaterializerDescriptor)`. + +A escolha favorece baixo overhead, compatibilidade, testabilidade, AOT e separacao entre generator e runtime. + +## Alternativas Avaliadas + +### `IGeneratedMaterializer` + +Descartada para esta etapa. Embora seja nominalmente simples, obrigaria instancias geradas ou singletons, misturaria metadata e execucao no mesmo objeto, adicionaria dispatch virtual/interface no hot path e encorajaria o runtime a depender de uma forma especifica de classe gerada. + +### Registro apenas por delegate + +Descartado. Delegate sozinho nao descreve entity/profile/shape/member paths, nao permite validar se a configuracao efetiva ainda corresponde ao codigo gerado e e pior para diagnostico futuro. + +### Descriptor internal com `InternalsVisibleTo` + +Descartado. O generator emite codigo no assembly consumidor, que nao tem acesso aos internals do core. `InternalsVisibleTo` nao e viavel para assemblies arbitrarios de consumidores. + +### Assembly scanning de materializers + +Descartado. Conflita com trimming/AOT e com a decisao de que registro explicito ou gerado deve bastar sem scanning. + +### Manifesto por assembly + +Adiado. Pode ser util para assemblies referenciados, mas amplia o escopo e nao e necessario para o contrato minimo desta etapa. + +## Compatibilidade + +APIs existentes continuam funcionando. + +O generator atual de registration continua valido porque `AddGeneratedMappings()` ainda chama apenas `AddMap()` e `AddProfile()`. + +Consumidores sem generated materializer seguem usando `QueryMapped*` com o fallback runtime. + +As annotations `RequiresUnreferencedCode` e `RequiresDynamicCode` em `QueryMapped*` permanecem corretas, pois qualquer chamada ainda pode cair no fallback runtime. + +Dommel nao foi alterado. + +## Testes Criados + +`test/Dapper.FluentMap.Tests/GeneratedMaterializerContractTests.cs` cobre: + +- registro e lookup default; +- missing materializer; +- profile default vs profile especifico; +- duplicate registration; +- descriptor/contract invalido; +- descriptor incompativel com configuracao efetiva; +- `QueryMapped*` usando generated quando registrado; +- fallback runtime quando generated esta ausente; +- concorrencia em lookup. + +## Fora do Escopo + +- Geracao Roslyn de materializers. +- Nested generated materialization. +- Constructor/value object generated path. +- TypeHandler generated boundary. +- Diagnostics publicos de generated vs runtime. +- Relaxar annotations de trimming/AOT. +- Benchmarks comparando generated real, pois o hot path gerado produtivo ainda nao existe. + +## Validacao Executada + +```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 run --project .\benchmarks\Dapper.FluentMap.Benchmarks\Dapper.FluentMap.Benchmarks.csproj --configuration Release --no-build -- --filter *MaterializationSteadyStateBenchmarks* +dotnet pack .\src\Dapper.FluentMap\Dapper.FluentMap.csproj --configuration Release --no-build --output .\artifacts\packages +``` + +Resultados: + +- restore: sucesso; +- build: sucesso, 0 warnings, 0 errors; +- testes: sucesso, 240 testes aprovados; +- benchmark smoke steady state: sucesso. +- pack do core: sucesso, gerou `artifacts/packages/Dapper.FluentMap.2.0.0.nupkg`. + +Warnings conhecidos no pack: + +- `NU5125` por `PackageLicenseUrl` legado; +- recomendacao NuGet para README de pacote. + +Resumo do benchmark smoke: + +| Method | Mean | Allocated | +| --- | ---: | ---: | +| DapperPure | 1.338 ms | 283.17 KB | +| DapperWithFluentMapRootMapping | 1.469 ms | 283.3 KB | +| QueryMappedSimple | 1.739 ms | 361.42 KB | +| QueryMappedImmutableConstructor | 1.670 ms | 423.92 KB | +| QueryMappedNestedObject | 1.495 ms | 377 KB | +| QueryMappedValueObject | 1.355 ms | 587.84 KB | + +Leitura: a rodada `ShortRun` e ruidosa, mas as alocacoes permaneceram alinhadas ao baseline de 7.2. Nao ha generated materializer produtivo para comparacao de ganho nesta etapa. diff --git a/.sdd/etapa-7/DECISIONS.md b/.sdd/etapa-7/DECISIONS.md index a9d5acd..af54310 100644 --- a/.sdd/etapa-7/DECISIONS.md +++ b/.sdd/etapa-7/DECISIONS.md @@ -169,3 +169,85 @@ A primeira fase de geracao deve focar explicit maps com `Map(...).ToColumn("lite - Nested/value object pode evoluir sobre uma base confiavel. - Naming policies built-in podem ser adicionadas depois com regras claras. - Conventions customizadas continuam no fallback. + +## ADR-7.3-001 - Contrato Publico por Descriptor e Delegate + +### Contexto + +O source generator emite codigo no assembly consumidor e, portanto, nao pode chamar contratos `internal` do core. Ao mesmo tempo, o runtime nao deve depender de uma classe especifica gerada por uma versao especifica do generator. + +### Decisao + +Adicionar contratos publicos pequenos em `Dapper.FluentMap.Materialization`: + +```text +GeneratedRowMaterializer +GeneratedMaterializerColumn +GeneratedMaterializerDescriptor +``` + +O registro acontece por APIs publicas aditivas em `FluentMapConfiguration`. O runtime guarda os descriptors em registry interno e usa delegate direto por linha quando o descriptor corresponde ao mapping efetivo. + +### Alternativas + +- `IGeneratedMaterializer`. +- Registro somente por delegate. +- Descriptor `internal` com `InternalsVisibleTo`. +- Descoberta por assembly scanning. + +### Consequencias + +- Generated code pode chamar o core sem permissao especial. +- O contrato preserva baixo overhead e separacao generator/runtime. +- A API publica nova e pequena, mas passa a ser contrato SemVer. +- O descriptor carrega metadata suficiente para validar fallback seguro. + +## ADR-7.3-002 - Validar Descriptor Contra Mapping Efetivo Antes de Usar + +### Contexto + +Descritores gerados podem ficar incompativeis com a configuracao efetiva por mudancas em maps, conventions, profiles ou uso dos dicionarios mutaveis legados. + +### Decisao + +O lookup generated deve validar, por coluna, se o descriptor corresponde ao mapping efetivo atual: + +- coluna materializada deve apontar para o mesmo `MemberPath`; +- coluna ignorada deve estar ignorada na configuracao efetiva; +- profile deve estar registrado; +- shape deve bater por entidade, profile e nomes ordenados. + +Quando a validacao falha, o runtime usa `NestedMaterializationPlan`. + +### Alternativas + +- Confiar sempre no descriptor registrado. +- Validar apenas entity/profile/shape. +- Invalidar/remover descriptors quando maps mudarem. + +### Consequencias + +- Preserva comportamento existente como autoridade funcional. +- Evita ordinais gerados para configuracao divergente. +- Adiciona um pequeno custo por query no lookup generated, nao por linha. +- Diagnostics publicos de motivo de fallback continuam para etapa futura. + +## ADR-7.3-003 - Nao Relaxar Annotations de QueryMapped + +### Contexto + +Mesmo com generated materializer registrado, `QueryMapped*` ainda pode cair no fallback runtime. + +### Decisao + +Manter `RequiresUnreferencedCode` e `RequiresDynamicCode` em `QueryMapped*`. + +### Alternativas + +- Remover annotations quando houver generated descriptor. +- Criar novas APIs AOT-only nesta etapa. + +### Consequencias + +- As APIs publicas continuam conservadoras para trimming/AOT. +- A reducao futura de warnings exige caminho dedicado ou garantia de generated-only ainda nao especificada. diff --git a/.sdd/etapa-7/STATUS.md b/.sdd/etapa-7/STATUS.md index f987e32..87ea0e0 100644 --- a/.sdd/etapa-7/STATUS.md +++ b/.sdd/etapa-7/STATUS.md @@ -37,6 +37,31 @@ Definir a arquitetura e a especificacao inicial para materializacao gerada no Fl - Executado `dotnet restore ./Dapper.FluentMap.sln`: sucesso. - Executado `dotnet build ./Dapper.FluentMap.sln --configuration Release --no-restore`: sucesso, 0 warnings, 0 errors. - Executado `dotnet test ./Dapper.FluentMap.sln --configuration Release --no-build`: sucesso, 231 testes aprovados. +- Criada a especificacao `.sdd/etapa-7/03-generated-materializer-contracts.md`. +- Adicionados contratos publicos: + - `GeneratedRowMaterializer`; + - `GeneratedMaterializerColumn`; + - `GeneratedMaterializerDescriptor`. +- Adicionadas APIs publicas em `FluentMapConfiguration` para registrar generated materializers default e por profile. +- Adicionado registry interno de generated materializers por entidade, profile e shape ordenado. +- Integrado lookup generated antes do fallback `NestedMaterializationPlan` em `QueryMapped*`. +- Mantido fallback runtime quando descriptor esta ausente ou incompativel com o mapping efetivo. +- Adicionados testes em `GeneratedMaterializerContractTests` cobrindo registro, lookup, missing materializer, fallback, profiles, duplicidade, contrato invalido e concorrencia. +- Executado `dotnet build .\src\Dapper.FluentMap\Dapper.FluentMap.csproj --configuration Release`: sucesso, 0 warnings, 0 errors. +- Executado `dotnet test .\test\Dapper.FluentMap.Tests\Dapper.FluentMap.Tests.csproj --configuration Release --filter "FullyQualifiedName~GeneratedMaterializerContractTests"`: sucesso, 9 testes aprovados. +- Executado `dotnet restore .\Dapper.FluentMap.sln`: sucesso. +- Executado `dotnet build .\Dapper.FluentMap.sln --configuration Release --no-restore`: sucesso, 0 warnings, 0 errors. +- Executado `dotnet test .\Dapper.FluentMap.sln --configuration Release --no-build`: sucesso, 240 testes aprovados. +- Executado benchmark smoke `dotnet run --project .\benchmarks\Dapper.FluentMap.Benchmarks\Dapper.FluentMap.Benchmarks.csproj --configuration Release --no-build -- --filter *MaterializationSteadyStateBenchmarks*`: sucesso. +- Benchmark smoke steady state resumido: + - DapperPure: 1.338 ms, 283.17 KB; + - DapperWithFluentMapRootMapping: 1.469 ms, 283.3 KB; + - QueryMappedSimple: 1.739 ms, 361.42 KB; + - QueryMappedImmutableConstructor: 1.670 ms, 423.92 KB; + - QueryMappedNestedObject: 1.495 ms, 377 KB; + - QueryMappedValueObject: 1.355 ms, 587.84 KB. +- Executado `dotnet pack .\src\Dapper.FluentMap\Dapper.FluentMap.csproj --configuration Release --no-build --output .\artifacts\packages`: sucesso, gerou `Dapper.FluentMap.2.0.0.nupkg`. +- Warnings conhecidos no pack: `NU5125` por `PackageLicenseUrl` legado e recomendacao NuGet para README de pacote. ## Em andamento @@ -44,12 +69,12 @@ Nenhum no escopo deste prompt apos o commit local. ## Proximos passos -1. Definir contratos runtime minimos para descriptor, lookup e fallback. +1. Estender generator para descobrir DSL estatica e emitir descriptors/materializers simples. 2. Prototipar flat/simple generated materialization para explicit maps literais. 3. Repetir benchmarks root/simple apos 7.4. 4. Expandir para nested objects, immutable objects e Value Objects. 5. Repetir benchmarks nested, immutable e Value Object apos 7.5. -6. Integrar generated lookup ao runtime antes do fallback. +6. Adicionar diagnostics de generated/fallback. 7. Repetir todos os benchmarks apos 7.6 para validar lookup generated/fallback integrado. 8. Validar trimming, Native AOT e performance antes de documentar ganhos. @@ -62,6 +87,9 @@ Nenhum no escopo deste prompt apos o commit local. - O projeto nao deve replicar Dapper.AOT. - Evolucao deve ser aditiva e sem breaking change. - Primeira cobertura gerada deve priorizar explicit maps com colunas literais. +- Generated materializers usam contrato publico por descriptor e delegate. +- Descritores gerados devem ser validados contra o mapping efetivo antes de uso. +- `QueryMapped*` mantem annotations de trimming/dynamic-code enquanto houver fallback runtime. ## Riscos conhecidos @@ -86,10 +114,12 @@ Nenhum no escopo deste prompt apos o commit local. - `src/Dapper.FluentMap.Generators/MappingRegistrationGenerator.cs` - `src/Dapper.FluentMap.Analyzers/FluentMapConfigurationAnalyzer.cs` - `test/Dapper.FluentMap.Tests/GeneratedMaterializerSpikeTests.cs` +- `test/Dapper.FluentMap.Tests/GeneratedMaterializerContractTests.cs` - `docs/sdd/etapa-6/04-generated-materializer-spike.md` - `.sdd/etapa-7/02-performance-spec.md` - `.sdd/etapa-7/02-performance-baseline.md` +- `.sdd/etapa-7/03-generated-materializer-contracts.md` ## Ultimo prompt executado -7.2 +7.3 diff --git a/README.md b/README.md index 68dee29..349600c 100644 --- a/README.md +++ b/README.md @@ -326,6 +326,8 @@ FluentMapper.Initialize(config => Generated registration calls the existing `AddMap()` / `AddProfile()` paths. It does not generate database materializers, scan referenced assemblies or replace `FluentMapper.Validate()`. +The core runtime also exposes low-level generated materializer registration contracts for generator-emitted code. These contracts are additive infrastructure; current consumers do not need to register materializers manually, and missing generated materializers continue to use the existing runtime fallback. + ## Trimming / Native AOT FluentMap has different levels of support depending on the API: @@ -747,6 +749,8 @@ FluentMapper.Initialize(config => 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()`. +O runtime principal também expõe contratos de baixo nível para registro de materializadores gerados por código emitido por generator. Esses contratos são infraestrutura aditiva; consumidores atuais não precisam registrar materializadores manualmente, e a ausência de materializadores gerados continua usando o fallback runtime existente. + ## Trimming / Native AOT FluentMap tem níveis diferentes de suporte conforme a API: diff --git a/src/Dapper.FluentMap/Configuration/FluentMapConfiguration.cs b/src/Dapper.FluentMap/Configuration/FluentMapConfiguration.cs index 0f0ecf3..eb0f3e7 100644 --- a/src/Dapper.FluentMap/Configuration/FluentMapConfiguration.cs +++ b/src/Dapper.FluentMap/Configuration/FluentMapConfiguration.cs @@ -6,6 +6,7 @@ using System.Reflection; using Dapper.FluentMap.Conventions; using Dapper.FluentMap.Mapping; +using Dapper.FluentMap.Materialization; using Dapper.FluentMap.Naming; namespace Dapper.FluentMap.Configuration @@ -73,6 +74,57 @@ public FluentMapConfiguration AddProfile< return this; } + /// + /// Registers a generated materializer for the default mapping of the specified entity type. + /// + /// The entity type produced by the materializer. + /// The ordered column shape and member bindings expected by the materializer. + /// The generated row materializer. + /// The current instance of . + public FluentMapConfiguration AddGeneratedMaterializer( + IEnumerable columns, + GeneratedRowMaterializer materializer) + where TEntity : class + { + return AddGeneratedMaterializer(new GeneratedMaterializerDescriptor(columns, materializer)); + } + + /// + /// Registers a generated materializer for the specified entity type and mapping profile. + /// + /// The entity type produced by the materializer. + /// The mapping profile marker type used by the materializer. + /// The ordered column shape and member bindings expected by the materializer. + /// The generated row materializer. + /// The current instance of . + public FluentMapConfiguration AddGeneratedMaterializer( + IEnumerable columns, + GeneratedRowMaterializer materializer) + where TEntity : class + where TProfile : IMappingProfile + { + return AddGeneratedMaterializer(new GeneratedMaterializerDescriptor(typeof(TProfile), columns, materializer)); + } + + /// + /// Registers a generated materializer descriptor. + /// + /// The entity type produced by the materializer. + /// The generated materializer descriptor. + /// The current instance of . + public FluentMapConfiguration AddGeneratedMaterializer( + GeneratedMaterializerDescriptor descriptor) + where TEntity : class + { + if (descriptor == null) + { + throw new ArgumentNullException(nameof(descriptor)); + } + + FluentMapper.Registry.AddGeneratedMaterializer(descriptor); + return this; + } + /// /// Finds exported entity map types in the specified assembly and adds them to the configuration of Dapper.FluentMap. /// diff --git a/src/Dapper.FluentMap/MappingRegistry.cs b/src/Dapper.FluentMap/MappingRegistry.cs index 54e1570..6ac3f11 100644 --- a/src/Dapper.FluentMap/MappingRegistry.cs +++ b/src/Dapper.FluentMap/MappingRegistry.cs @@ -2,6 +2,7 @@ using System.Collections.Concurrent; using System.Collections.Generic; using System.Collections.ObjectModel; +using System.Data; using System.Diagnostics.CodeAnalysis; using System.Linq; using System.Reflection; @@ -22,6 +23,9 @@ internal sealed class MappingRegistry private readonly ConcurrentDictionary _materializationPlanCache = new ConcurrentDictionary(); + private readonly ConcurrentDictionary _generatedMaterializers = + new ConcurrentDictionary(); + internal ConcurrentDictionary EntityMaps { get; } = new ConcurrentDictionary(); @@ -35,6 +39,8 @@ internal sealed class MappingRegistry internal int MaterializationPlanCacheEntryCount => _materializationPlanCache.Count; + internal int GeneratedMaterializerCount => _generatedMaterializers.Count; + internal IReadOnlyDictionary GetEntityMapsSnapshot() { var snapshot = EntityMaps @@ -231,11 +237,7 @@ internal NestedMaterializationPlan GetMaterializationPlan(Type type, Type profil throw new ArgumentNullException(nameof(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}'."); - } + EnsureProfileRegistered(type, profileType); var cacheKey = new MaterializationPlanCacheKey(type, profileType, columnNames); return _materializationPlanCache.GetOrAdd( @@ -243,6 +245,62 @@ internal NestedMaterializationPlan GetMaterializationPlan(Type type, Type profil key => NestedMaterializationPlan.Create(key.Type, key.ProfileType, key.ColumnNames, this)); } + internal void AddGeneratedMaterializer(GeneratedMaterializerDescriptor descriptor) + where TEntity : class + { + if (descriptor == null) + { + throw new ArgumentNullException(nameof(descriptor)); + } + + var key = new MaterializationPlanCacheKey( + descriptor.EntityType, + descriptor.ProfileType, + descriptor.Columns.Select(column => column.ColumnName)); + var entry = GeneratedMaterializerEntry.Create(descriptor); + + if (!_generatedMaterializers.TryAdd(key, entry)) + { + var profileContext = descriptor.ProfileType == null + ? string.Empty + : $" and profile '{descriptor.ProfileType.FullName}'"; + + throw new FluentMapConfigurationException( + $"Entity '{descriptor.EntityType.FullName}' already has a generated materializer registered for the same column shape{profileContext}."); + } + } + + internal bool TryGetGeneratedMaterializer( + Type type, + Type profileType, + string[] columnNames, + out Func materializer) + { + if (type == null) + { + throw new ArgumentNullException(nameof(type)); + } + + if (columnNames == null) + { + throw new ArgumentNullException(nameof(columnNames)); + } + + EnsureProfileRegistered(type, profileType); + + var cacheKey = new MaterializationPlanCacheKey(type, profileType, columnNames); + GeneratedMaterializerEntry entry; + if (!_generatedMaterializers.TryGetValue(cacheKey, out entry) || + !GeneratedMaterializerMatchesEffectiveMapping(type, profileType, entry.Columns)) + { + materializer = null; + return false; + } + + materializer = entry.Materialize; + return true; + } + internal void ValidateConfiguration() { var errors = new List(); @@ -384,6 +442,7 @@ internal void Reset(params Type[] dapperTypes) TypeConventions.Clear(); _propertyMapCache.Clear(); _materializationPlanCache.Clear(); + _generatedMaterializers.Clear(); if (dapperTypes == null) { @@ -402,6 +461,74 @@ private void SetDapperTypeMap(Type type) SqlMapper.SetTypeMap(type, instance); } + private void EnsureProfileRegistered(Type type, Type profileType) + { + if (profileType != null && !ProfileMaps.ContainsKey(new MappingProfileKey(type, profileType))) + { + throw new FluentMapConfigurationException( + $"Entity '{type.FullName}' does not have a registered mapping profile '{profileType.FullName}'."); + } + } + + private bool GeneratedMaterializerMatchesEffectiveMapping( + Type type, + Type profileType, + IReadOnlyList columns) + { + var defaultTypeMap = new DefaultTypeMap(type); + + foreach (var column in columns) + { + var fluentMap = GetProfilePropertyMap(type, profileType, column.ColumnName); + if (fluentMap != null) + { + if (column.Ignored) + { + if (!fluentMap.Ignored) + { + return false; + } + + continue; + } + + if (fluentMap.Ignored) + { + return false; + } + + var memberPath = PropertyMapIdentity.GetMemberPath(fluentMap).ToString(); + if (!string.Equals(memberPath, column.MemberPath, StringComparison.Ordinal)) + { + return false; + } + + continue; + } + + if (column.Ignored) + { + return false; + } + + var defaultMember = defaultTypeMap.GetMember(column.ColumnName); + var defaultMemberPath = defaultMember == null + ? null + : defaultMember.Property != null + ? defaultMember.Property.Name + : defaultMember.Field != null + ? defaultMember.Field.Name + : null; + + if (!string.Equals(defaultMemberPath, column.MemberPath, StringComparison.Ordinal)) + { + return false; + } + } + + return true; + } + private void InvalidateType(Type type) { foreach (var key in _propertyMapCache.Keys.Where(k => k.Type == type)) @@ -786,6 +913,29 @@ internal MappingCacheEntry(IPropertyMap propertyMap) internal PropertyInfo PropertyInfo { get; } } + private sealed class GeneratedMaterializerEntry + { + private GeneratedMaterializerEntry( + IReadOnlyList columns, + Func materialize) + { + Columns = columns; + Materialize = materialize; + } + + internal static GeneratedMaterializerEntry Create(GeneratedMaterializerDescriptor descriptor) + where TEntity : class + { + return new GeneratedMaterializerEntry( + descriptor.Columns, + record => descriptor.Materializer(record)); + } + + internal IReadOnlyList Columns { get; } + + internal Func Materialize { get; } + } + private sealed class MappingDiagnosticDescriptor { private MappingDiagnosticDescriptor(IPropertyMap map, MappingSource source, Type inheritedFrom, Type conventionType) diff --git a/src/Dapper.FluentMap/Materialization/GeneratedMaterializerColumn.cs b/src/Dapper.FluentMap/Materialization/GeneratedMaterializerColumn.cs new file mode 100644 index 0000000..46fd177 --- /dev/null +++ b/src/Dapper.FluentMap/Materialization/GeneratedMaterializerColumn.cs @@ -0,0 +1,63 @@ +using System; + +namespace Dapper.FluentMap.Materialization +{ + /// + /// Describes how a generated materializer expects one column in its ordered row shape to map. + /// + public sealed class GeneratedMaterializerColumn + { + private GeneratedMaterializerColumn(string columnName, string memberPath, bool ignored) + { + if (string.IsNullOrWhiteSpace(columnName)) + { + throw new ArgumentException("Column name cannot be null, empty or whitespace.", nameof(columnName)); + } + + if (!ignored && string.IsNullOrWhiteSpace(memberPath)) + { + throw new ArgumentException("Member path cannot be null, empty or whitespace for a materialized column.", nameof(memberPath)); + } + + ColumnName = columnName; + MemberPath = memberPath; + Ignored = ignored; + } + + /// + /// Gets the column name expected at this ordinal. + /// + public string ColumnName { get; } + + /// + /// Gets the mapped member path expected for this column, or for ignored columns. + /// + public string MemberPath { get; } + + /// + /// Gets a value indicating whether the current mapping must ignore this column. + /// + public bool Ignored { get; } + + /// + /// Creates a descriptor for a materialized column. + /// + /// The column name expected at this ordinal. + /// The member path materialized from the column. + /// The generated materializer column descriptor. + public static GeneratedMaterializerColumn Map(string columnName, string memberPath) + { + return new GeneratedMaterializerColumn(columnName, memberPath, ignored: false); + } + + /// + /// Creates a descriptor for a column that must be ignored by the effective FluentMap configuration. + /// + /// The column name expected at this ordinal. + /// The generated materializer column descriptor. + public static GeneratedMaterializerColumn Ignore(string columnName) + { + return new GeneratedMaterializerColumn(columnName, memberPath: null, ignored: true); + } + } +} diff --git a/src/Dapper.FluentMap/Materialization/GeneratedMaterializerDescriptor.cs b/src/Dapper.FluentMap/Materialization/GeneratedMaterializerDescriptor.cs new file mode 100644 index 0000000..f4928e2 --- /dev/null +++ b/src/Dapper.FluentMap/Materialization/GeneratedMaterializerDescriptor.cs @@ -0,0 +1,90 @@ +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.Linq; +using Dapper.FluentMap.Mapping; + +namespace Dapper.FluentMap.Materialization +{ + /// + /// Describes one generated materializer for an entity, optional profile and ordered column shape. + /// + /// The entity type produced by the materializer. + public sealed class GeneratedMaterializerDescriptor + where TEntity : class + { + private readonly GeneratedMaterializerColumn[] _columns; + + /// + /// Initializes a new instance of the class. + /// + /// The ordered column shape and member bindings expected by the materializer. + /// The generated row materializer. + public GeneratedMaterializerDescriptor( + IEnumerable columns, + GeneratedRowMaterializer materializer) + : this(profileType: null, columns, materializer) + { + } + + /// + /// Initializes a new instance of the class. + /// + /// The mapping profile type, or for the default map. + /// The ordered column shape and member bindings expected by the materializer. + /// The generated row materializer. + public GeneratedMaterializerDescriptor( + Type profileType, + IEnumerable columns, + GeneratedRowMaterializer materializer) + { + if (profileType != null && !typeof(IMappingProfile).IsAssignableFrom(profileType)) + { + throw new ArgumentException( + $"Profile type '{profileType.FullName}' must implement IMappingProfile.", + nameof(profileType)); + } + + if (columns == null) + { + throw new ArgumentNullException(nameof(columns)); + } + + Materializer = materializer ?? throw new ArgumentNullException(nameof(materializer)); + ProfileType = profileType; + _columns = columns.ToArray(); + + if (_columns.Any(column => column == null)) + { + throw new ArgumentException("Column descriptors cannot contain null entries.", nameof(columns)); + } + + if (_columns.Length == 0) + { + throw new ArgumentException("At least one column descriptor is required.", nameof(columns)); + } + + Columns = new ReadOnlyCollection(_columns); + } + + /// + /// Gets the entity type produced by the materializer. + /// + public Type EntityType => typeof(TEntity); + + /// + /// Gets the mapping profile type, or for the default map. + /// + public Type ProfileType { get; } + + /// + /// Gets the ordered column shape and member bindings expected by the materializer. + /// + public IReadOnlyList Columns { get; } + + /// + /// Gets the generated row materializer. + /// + public GeneratedRowMaterializer Materializer { get; } + } +} diff --git a/src/Dapper.FluentMap/Materialization/GeneratedRowMaterializer.cs b/src/Dapper.FluentMap/Materialization/GeneratedRowMaterializer.cs new file mode 100644 index 0000000..b2cdd45 --- /dev/null +++ b/src/Dapper.FluentMap/Materialization/GeneratedRowMaterializer.cs @@ -0,0 +1,13 @@ +using System.Data; + +namespace Dapper.FluentMap.Materialization +{ + /// + /// Represents generated code that materializes the current row from an . + /// + /// The entity type produced by the materializer. + /// The data record positioned on the row to materialize. + /// The materialized entity. + public delegate TEntity GeneratedRowMaterializer(IDataRecord record) + where TEntity : class; +} diff --git a/src/Dapper.FluentMap/QueryMappedExtensions.cs b/src/Dapper.FluentMap/QueryMappedExtensions.cs index 9c51b76..4e0420f 100644 --- a/src/Dapper.FluentMap/QueryMappedExtensions.cs +++ b/src/Dapper.FluentMap/QueryMappedExtensions.cs @@ -341,9 +341,25 @@ private static IEnumerable Materialize< where TEntity : class { var columnNames = GetColumnNames(reader); - var plan = FluentMapper.Registry.GetMaterializationPlan(typeof(TEntity), profileType, columnNames); var results = new List(); + Func generatedMaterializer; + if (FluentMapper.Registry.TryGetGeneratedMaterializer( + typeof(TEntity), + profileType, + columnNames, + out generatedMaterializer)) + { + while (reader.Read()) + { + results.Add((TEntity)generatedMaterializer(reader)); + } + + return results; + } + + var plan = FluentMapper.Registry.GetMaterializationPlan(typeof(TEntity), profileType, columnNames); + while (reader.Read()) { results.Add((TEntity)plan.Materialize(reader)); diff --git a/test/Dapper.FluentMap.Tests/GeneratedMaterializerContractTests.cs b/test/Dapper.FluentMap.Tests/GeneratedMaterializerContractTests.cs new file mode 100644 index 0000000..e54853b --- /dev/null +++ b/test/Dapper.FluentMap.Tests/GeneratedMaterializerContractTests.cs @@ -0,0 +1,385 @@ +using System; +using System.Data; +using System.Linq; +using System.Threading.Tasks; +using Dapper.FluentMap.Mapping; +using Dapper.FluentMap.Materialization; +using Microsoft.Data.Sqlite; +using Xunit; + +namespace Dapper.FluentMap.Tests +{ + public class GeneratedMaterializerContractTests + { + [Fact] + public void RegistryShouldResolveRegisteredGeneratedMaterializer() + { + PreTest(typeof(GeneratedContractCustomer)); + + try + { + FluentMapper.Initialize(configuration => + { + configuration.AddMap(new GeneratedContractCustomerMap()); + configuration.AddGeneratedMaterializer( + DefaultColumns(), + ReadDefaultGeneratedCustomer); + }); + + var found = FluentMapper.Registry.TryGetGeneratedMaterializer( + typeof(GeneratedContractCustomer), + profileType: null, + columnNames: new[] { "customer_id", "full_name" }, + out var materializer); + + using (var reader = CreateReader( + new[] { "customer_id", "full_name" }, + new object[] { 11, "Ada" })) + { + Assert.True(found); + Assert.NotNull(materializer); + Assert.True(reader.Read()); + + var customer = Assert.IsType(materializer(reader)); + Assert.Equal(11, customer.Id); + Assert.Equal("generated:Ada", customer.Name); + Assert.Equal(1, FluentMapper.Registry.GeneratedMaterializerCount); + } + } + finally + { + PreTest(typeof(GeneratedContractCustomer)); + } + } + + [Fact] + public void RegistryShouldReturnFalseWhenGeneratedMaterializerIsMissing() + { + PreTest(typeof(GeneratedContractCustomer)); + + try + { + FluentMapper.Initialize(configuration => configuration.AddMap(new GeneratedContractCustomerMap())); + + var found = FluentMapper.Registry.TryGetGeneratedMaterializer( + typeof(GeneratedContractCustomer), + profileType: null, + columnNames: new[] { "customer_id", "full_name" }, + out var materializer); + + Assert.False(found); + Assert.Null(materializer); + } + finally + { + PreTest(typeof(GeneratedContractCustomer)); + } + } + + [Fact] + public void RegistryShouldResolveGeneratedMaterializerByProfile() + { + PreTest(typeof(GeneratedContractCustomer)); + + try + { + FluentMapper.Initialize(configuration => + { + configuration.AddMap(new GeneratedContractCustomerMap()); + configuration.AddProfile(); + configuration.AddGeneratedMaterializer( + DefaultColumns(), + ReadDefaultGeneratedCustomer); + configuration.AddGeneratedMaterializer( + LegacyColumns(), + ReadLegacyGeneratedCustomer); + }); + + var defaultFound = FluentMapper.Registry.TryGetGeneratedMaterializer( + typeof(GeneratedContractCustomer), + profileType: null, + columnNames: new[] { "customer_id", "full_name" }, + out var defaultMaterializer); + var profileFound = FluentMapper.Registry.TryGetGeneratedMaterializer( + typeof(GeneratedContractCustomer), + typeof(GeneratedLegacyProfile), + new[] { "legacy_id", "legal_name" }, + out var profileMaterializer); + + Assert.True(defaultFound); + Assert.True(profileFound); + Assert.NotSame(defaultMaterializer, profileMaterializer); + } + finally + { + PreTest(typeof(GeneratedContractCustomer)); + } + } + + [Fact] + public void RegistryShouldRejectDuplicateGeneratedMaterializerForSameShape() + { + PreTest(typeof(GeneratedContractCustomer)); + + try + { + var exception = Assert.Throws( + () => FluentMapper.Initialize(configuration => + { + configuration.AddGeneratedMaterializer( + DefaultColumns(), + ReadDefaultGeneratedCustomer); + configuration.AddGeneratedMaterializer( + DefaultColumns(), + ReadDefaultGeneratedCustomer); + })); + + Assert.Contains("already has a generated materializer", exception.Message); + Assert.Equal(1, FluentMapper.Registry.GeneratedMaterializerCount); + } + finally + { + PreTest(typeof(GeneratedContractCustomer)); + } + } + + [Fact] + public void RegistryShouldIgnoreGeneratedMaterializerWhenContractDoesNotMatchEffectiveMapping() + { + PreTest(typeof(GeneratedContractCustomer)); + + try + { + FluentMapper.Initialize(configuration => + { + configuration.AddMap(new GeneratedContractCustomerMap()); + configuration.AddGeneratedMaterializer( + new[] + { + GeneratedMaterializerColumn.Map("customer_id", nameof(GeneratedContractCustomer.Name)), + GeneratedMaterializerColumn.Map("full_name", nameof(GeneratedContractCustomer.Name)) + }, + ReadDefaultGeneratedCustomer); + }); + + var found = FluentMapper.Registry.TryGetGeneratedMaterializer( + typeof(GeneratedContractCustomer), + profileType: null, + columnNames: new[] { "customer_id", "full_name" }, + out var materializer); + + Assert.False(found); + Assert.Null(materializer); + } + finally + { + PreTest(typeof(GeneratedContractCustomer)); + } + } + + [Fact] + public void DescriptorShouldRejectInvalidContracts() + { + var columns = DefaultColumns(); + + Assert.Throws(() => GeneratedMaterializerColumn.Map(" ", nameof(GeneratedContractCustomer.Id))); + Assert.Throws(() => GeneratedMaterializerColumn.Map("customer_id", string.Empty)); + Assert.Throws(() => new GeneratedMaterializerDescriptor(columns, null)); + Assert.Throws(() => new GeneratedMaterializerDescriptor( + typeof(GeneratedContractCustomer), + columns, + ReadDefaultGeneratedCustomer)); + Assert.Throws(() => new GeneratedMaterializerDescriptor( + new GeneratedMaterializerColumn[] { null }, + ReadDefaultGeneratedCustomer)); + } + + [Fact] + [Trait("Category", "Integration")] + public void QueryMappedShouldUseGeneratedMaterializerWhenRegistered() + { + PreTest(typeof(GeneratedContractCustomer)); + + try + { + FluentMapper.Initialize(configuration => + { + configuration.AddMap(new GeneratedContractCustomerMap()); + configuration.AddGeneratedMaterializer( + DefaultColumns(), + ReadDefaultGeneratedCustomer); + }); + + using (var connection = OpenConnection()) + { + var customer = connection.QueryMappedSingle( + "SELECT 31 AS customer_id, 'Ada' AS full_name;"); + + Assert.Equal(31, customer.Id); + Assert.Equal("generated:Ada", customer.Name); + Assert.Equal(0, FluentMapper.Registry.MaterializationPlanCacheEntryCount); + } + } + finally + { + PreTest(typeof(GeneratedContractCustomer)); + } + } + + [Fact] + [Trait("Category", "Integration")] + public void QueryMappedShouldFallBackToRuntimeWhenGeneratedMaterializerIsMissing() + { + PreTest(typeof(GeneratedContractCustomer)); + + try + { + FluentMapper.Initialize(configuration => configuration.AddMap(new GeneratedContractCustomerMap())); + + using (var connection = OpenConnection()) + { + var customer = connection.QueryMappedSingle( + "SELECT 41 AS customer_id, 'Runtime' AS full_name;"); + + Assert.Equal(41, customer.Id); + Assert.Equal("Runtime", customer.Name); + Assert.Equal(1, FluentMapper.Registry.MaterializationPlanCacheEntryCount); + } + } + finally + { + PreTest(typeof(GeneratedContractCustomer)); + } + } + + [Fact] + public void GeneratedLookupShouldRemainStableUnderConcurrentReads() + { + PreTest(typeof(GeneratedContractCustomer)); + + try + { + FluentMapper.Initialize(configuration => + { + configuration.AddMap(new GeneratedContractCustomerMap()); + configuration.AddGeneratedMaterializer( + DefaultColumns(), + ReadDefaultGeneratedCustomer); + }); + + var results = new bool[100]; + Parallel.For(0, results.Length, index => + { + results[index] = FluentMapper.Registry.TryGetGeneratedMaterializer( + typeof(GeneratedContractCustomer), + profileType: null, + columnNames: new[] { "customer_id", "full_name" }, + out var materializer) && materializer != null; + }); + + Assert.All(results, Assert.True); + Assert.Equal(1, FluentMapper.Registry.GeneratedMaterializerCount); + Assert.Equal(0, FluentMapper.Registry.MaterializationPlanCacheEntryCount); + } + finally + { + PreTest(typeof(GeneratedContractCustomer)); + } + } + + private static GeneratedMaterializerColumn[] DefaultColumns() + { + return new[] + { + GeneratedMaterializerColumn.Map("customer_id", nameof(GeneratedContractCustomer.Id)), + GeneratedMaterializerColumn.Map("full_name", nameof(GeneratedContractCustomer.Name)) + }; + } + + private static GeneratedMaterializerColumn[] LegacyColumns() + { + return new[] + { + GeneratedMaterializerColumn.Map("legacy_id", nameof(GeneratedContractCustomer.Id)), + GeneratedMaterializerColumn.Map("legal_name", nameof(GeneratedContractCustomer.Name)) + }; + } + + private static GeneratedContractCustomer ReadDefaultGeneratedCustomer(IDataRecord record) + { + return new GeneratedContractCustomer + { + Id = Convert.ToInt32(record.GetValue(0)), + Name = "generated:" + Convert.ToString(record.GetValue(1)) + }; + } + + private static GeneratedContractCustomer ReadLegacyGeneratedCustomer(IDataRecord record) + { + return new GeneratedContractCustomer + { + Id = Convert.ToInt32(record.GetValue(0)), + Name = "legacy:" + Convert.ToString(record.GetValue(1)) + }; + } + + 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 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 GeneratedLegacyProfile : IMappingProfile + { + } + + private sealed class GeneratedContractCustomer + { + public int Id { get; set; } + + public string Name { get; set; } + } + + private sealed class GeneratedContractCustomerMap : EntityMap + { + public GeneratedContractCustomerMap() + { + Map(customer => customer.Id).ToColumn("customer_id"); + Map(customer => customer.Name).ToColumn("full_name"); + } + } + + private sealed class GeneratedContractCustomerLegacyMap : + EntityMap, + IProfileMap + { + public GeneratedContractCustomerLegacyMap() + { + Map(customer => customer.Id).ToColumn("legacy_id"); + Map(customer => customer.Name).ToColumn("legal_name"); + } + } + } +} From 200c7af513b8b8ca41a6e9225f24cc84be0a9633 Mon Sep 17 00:00:00 2001 From: Rodrigo de Oliveira Date: Tue, 28 Jul 2026 10:04:52 -0300 Subject: [PATCH 04/49] feat(generator): generate flat entity materializers --- .sdd/etapa-7/02-performance-baseline.md | 57 ++ .../04-flat-generated-materializers.md | 137 +++ .sdd/etapa-7/STATUS.md | 45 +- README.md | 8 +- .../Dapper.FluentMap.Benchmarks.csproj | 1 + .../Dapper.FluentMap.Benchmarks/Program.cs | 14 +- .../AnalyzerReleases.Unshipped.md | 1 + .../MappingRegistrationGenerator.cs | 938 +++++++++++++++++- src/Dapper.FluentMap.Generators/README.md | 6 +- .../GeneratedRegistrationIntegrationTests.cs | 33 + .../MappingRegistrationGeneratorTests.cs | 135 +++ 11 files changed, 1342 insertions(+), 33 deletions(-) create mode 100644 .sdd/etapa-7/04-flat-generated-materializers.md diff --git a/.sdd/etapa-7/02-performance-baseline.md b/.sdd/etapa-7/02-performance-baseline.md index 194ba14..fb1bc6d 100644 --- a/.sdd/etapa-7/02-performance-baseline.md +++ b/.sdd/etapa-7/02-performance-baseline.md @@ -133,3 +133,60 @@ Repetir estes benchmarks: - apos 7.4: `DapperPure`, `DapperWithFluentMapRootMapping`, `QueryMappedSimple` e cold root/simple; - apos 7.5: `QueryMappedImmutableConstructor`, `QueryMappedNestedObject`, `QueryMappedValueObject` e seus equivalentes generated; - apos 7.6: todos os steady state e cold start para validar lookup generated/fallback integrado. + +## Apos Prompt 7.4 + +Prompt 7.4 adicionou materializers gerados para maps flat simples e alterou o benchmark steady state para registrar maps por `AddGeneratedMappings()`. Com isso, `QueryMappedSimple` e `QueryMappedImmutableConstructor` usam generated materializer quando a query retorna o shape canonico gerado. `QueryMappedNestedObject` e `QueryMappedValueObject` continuam no fallback runtime. + +### Comandos Executados + +Rodada steady state: + +```bash +dotnet run --project ./benchmarks/Dapper.FluentMap.Benchmarks/Dapper.FluentMap.Benchmarks.csproj --configuration Release --no-build -- --filter *MaterializationSteadyStateBenchmarks* +``` + +Rodada cold start existente: + +```bash +dotnet run --project ./benchmarks/Dapper.FluentMap.Benchmarks/Dapper.FluentMap.Benchmarks.csproj --configuration Release --no-build -- --filter *MaterializationColdStartBenchmarks* +``` + +Tambem foi tentado adicionar um benchmark cold dedicado para `QueryMapped` flat gerado. A tentativa nao foi mantida porque BenchmarkDotNet invoca o metodo mais de uma vez no mesmo processo para estatisticas extras, e o contrato publico atual nao expoe reset de generated materializers; a segunda chamada a `AddGeneratedMappings()` duplicava o descriptor. Nao foi criada API publica nova apenas para o benchmark. + +### Resultados - Steady State + +Job: `ShortRun`, `LaunchCount=1`, `WarmupCount=3`, `IterationCount=3`. + +| Method | Mean | StdDev | Ratio | Gen0 | Gen1 | Allocated | Alloc Ratio | +| --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: | +| QueryMappedValueObject | 1.202 ms | 0.0732 ms | 0.89 | 140.6250 | 41.0156 | 587.84 KB | 2.08 | +| DapperPure | 1.355 ms | 0.0485 ms | 1.00 | 68.3594 | - | 283.17 KB | 1.00 | +| QueryMappedNestedObject | 1.422 ms | 0.1847 ms | 1.05 | 91.7969 | 29.2969 | 377 KB | 1.33 | +| DapperWithFluentMapRootMapping | 1.456 ms | 0.0464 ms | 1.08 | 68.3594 | - | 283.3 KB | 1.00 | +| QueryMappedImmutableConstructor | 1.610 ms | 0.0769 ms | 1.19 | 62.5000 | 11.7188 | 261.05 KB | 0.92 | +| QueryMappedSimple | 1.617 ms | 0.1008 ms | 1.19 | 62.5000 | 11.7188 | 261.12 KB | 0.92 | + +### Leitura - Steady State + +- `QueryMappedSimple` reduziu alocacao de aproximadamente `361 KB` para `261 KB` por 1000 linhas quando o generated materializer foi usado. +- `QueryMappedImmutableConstructor` reduziu alocacao de aproximadamente `424 KB` para `261 KB` por 1000 linhas. +- O tempo continua ruidoso em `ShortRun`; nao ha base estatistica para prometer ganho de tempo. +- `QueryMappedNestedObject` e `QueryMappedValueObject` ainda usam fallback runtime. As variacoes de tempo nesses cenarios devem ser tratadas como ruido da rodada, nao como efeito do prompt 7.4. + +### Resultados - Cold Start + +Job: `RunStrategy=ColdStart`, `LaunchCount=8`, `WarmupCount=0`, `IterationCount=1`. + +| Method | Mean | StdDev | Ratio | Allocated | Alloc Ratio | +| --- | ---: | ---: | ---: | ---: | ---: | +| DapperPureColdStart | 183.5 ms | 27.79 ms | 1.02 | 285.95 KB | 1.00 | +| QueryMappedValueObjectColdStart | 251.8 ms | 25.75 ms | 1.40 | 645.09 KB | 2.26 | +| FluentMapRootMappingColdStart | 271.1 ms | 59.49 ms | 1.51 | 353.05 KB | 1.23 | +| QueryMappedNestedColdStart | 276.7 ms | 55.80 ms | 1.54 | 442.84 KB | 1.55 | + +### Leitura - Cold Start + +- Cold start continuou com variancia alta e outliers. +- A rodada cold valida que os cenarios existentes continuam executando apos a integracao do generator no projeto de benchmarks. +- Nao ha numero cold dedicado para generated flat neste prompt por causa da limitacao de reset publico descrita acima. diff --git a/.sdd/etapa-7/04-flat-generated-materializers.md b/.sdd/etapa-7/04-flat-generated-materializers.md new file mode 100644 index 0000000..d2611ff --- /dev/null +++ b/.sdd/etapa-7/04-flat-generated-materializers.md @@ -0,0 +1,137 @@ +# Flat Generated Materializers + +Status: SPECIFICATION + IMPLEMENTATION +Prompt: 7.4 +Data: 2026-07-28 + +## Objetivo + +Implementar a primeira cobertura produtiva de materializadores gerados para entidades flat simples, preservando o fallback runtime como autoridade funcional. + +O generator continua emitindo `AddGeneratedMappings()`. A partir deste prompt, quando um map e estaticamente geravel, a mesma chamada tambem registra um `GeneratedRowMaterializer` pelo contrato publico criado no prompt 7.3. + +## Casos Suportados + +O caminho gerado cobre somente maps declarados na compilacao atual que ja eram elegiveis para registro gerado: + +- map concreto, fechado, `public` ou `internal`; +- map com construtor publico sem parametros; +- map implementando exatamente um `IEntityMap`, com `TEntity` class; +- no maximo um `IProfileMap`, quando for profile; +- chamadas diretas no construtor `Map(entity => entity.Property).ToColumn("literal")`; +- `Ignore()` em propriedade root; +- propriedades root, flat, sem caminhos aninhados; +- propriedades escalares simples: primitivos numericos, `bool`, `char`, `string`, `decimal`, `DateTime`, `Guid`, enums e `Nullable` desses tipos; +- entidade mutavel com construtor publico sem parametros e setters publicos para todas as propriedades materializadas; +- entidade imutavel simples com um unico construtor publico que corresponda a todas as propriedades materializadas por nome de parametro e tipo apos unwrap de `Nullable`; +- profiles simples com os mesmos limites acima. + +## Casos Nao Suportados + +Estes casos continuam pelo fallback runtime: + +- nested objects, nested value objects e member paths com mais de uma propriedade; +- collections, graph aggregation e factory methods; +- `IncludeBase()`; +- conventions e naming policies como fonte gerada de colunas; +- nomes de coluna calculados ou nao literais; +- chamadas de mapping indiretas, helpers arbitrarios ou chains desconhecidas; +- propriedades sem setter que nao sejam vinculadas por um construtor simples; +- construtores ambiguos ou parcialmente vinculados; +- TypeHandlers do Dapper no caminho gerado; +- shapes de query que nao correspondam exatamente ao shape ordenado registrado; +- colunas extras, ausentes ou em ordem diferente do descriptor gerado. + +Ausencia de suporte gerado nao e erro funcional. O runtime existente segue materializando quando a configuracao for valida para o FluentMap. + +## Generated Code Shape + +Para cada map suportado, o generator emite: + +```csharp +configuration + .AddMap() + .AddGeneratedMaterializer( + new[] + { + GeneratedMaterializerColumn.Map("customer_id", "Id"), + GeneratedMaterializerColumn.Map("full_name", "FullName") + }, + DapperFluentMapGeneratedMaterializers.Read0); +``` + +Para profiles: + +```csharp +configuration + .AddProfile() + .AddGeneratedMaterializer( + columns, + DapperFluentMapGeneratedMaterializers.Read1); +``` + +O materializador emitido e uma classe estatica interna no assembly consumidor. Ele recebe `IDataRecord`, valida `record != null`, le valores por ordinal fixo e cria a entidade por: + +- construtor publico sem parametros + atribuicoes diretas; ou +- construtor publico simples com argumentos locais lidos antes da chamada. + +## Ordinal Handling + +O descriptor usa a ordem das chamadas `Map(...)` reconhecidas no construtor do map. O ordinal do codigo gerado e a posicao do item nesse descriptor. + +O runtime so usa o delegate gerado quando o shape ordenado do `IDataReader` bate com o descriptor registrado. Se a query retornar as mesmas colunas em outra ordem, colunas extras ou colunas ausentes, nao ha match e o fallback runtime e usado. + +## Null Conversion + +O helper gerado segue a semantica escalar do runtime para o subconjunto suportado: + +- `DBNull` retorna `default(T)`; +- para reference types e `Nullable`, isso resulta em `null`; +- para value types nao anulaveis, isso resulta em `default`; +- valores ja tipados sao retornados diretamente; +- enums aceitam string ou valor numerico; +- `Guid` aceita string; +- demais escalares usam `Convert.ChangeType(..., CultureInfo.InvariantCulture)`. + +TypeHandlers do Dapper permanecem fora deste prompt e usam fallback. + +## Constructor Selection + +O generator prefere entidade mutavel quando existe construtor publico sem parametros e todos os membros materializados possuem setter publico. + +Quando isso nao e possivel, ele aceita somente um construtor publico que: + +- tenha exatamente a mesma quantidade de parametros que propriedades materializadas; +- vincule cada parametro a uma propriedade mapeada por nome case-insensitive; +- tenha tipo igual ao tipo da propriedade apos unwrap de `Nullable`; +- use cada propriedade materializada exatamente uma vez. + +Se nenhum construtor ou mais de um construtor for seguro, o generator nao emite materializer e o runtime fallback decide. + +## Error Handling + +Situacoes estaticamente invalidas ja cobertas pelo generator/analyzer continuam como diagnostics de erro existentes, por exemplo maps genericos invalidos e duplicidade de maps gerados. + +Situacoes validas para runtime mas nao suportadas pelo caminho gerado produzem diagnostic informativo `DFM011` e continuam registradas via `AddMap()` ou `AddProfile()`. + +Falhas de dominio ao executar construtor gerado sao encapsuladas em `FluentMapConfigurationException` com a excecao original como inner exception. Falhas de conversao escalar seguem o comportamento natural da conversao, como no runtime atual antes da chamada de construtor. + +## Fallback + +Fallback e integral: + +- sem descriptor para entity/profile/shape: `NestedMaterializationPlan`; +- descriptor com shape diferente: `NestedMaterializationPlan`; +- descriptor incompativel com o mapping efetivo atual: `NestedMaterializationPlan`; +- map com feature nao suportada pelo generator: `NestedMaterializationPlan`; +- consumidor sem pacote generator: comportamento runtime existente. + +O descriptor continua sendo validado contra o mapping efetivo no runtime antes de usar o delegate gerado. + +## Limitacoes Restantes + +- O generator ainda nao interpreta um metadata model compartilhado; ele reconhece um subconjunto estatico da DSL diretamente por Roslyn. +- A cobertura gerada e por shape canonico de map, nao por SQL real. +- `QueryMapped*` mantem annotations de trimming/dynamic-code porque qualquer chamada ainda pode cair no fallback. +- Nao ha diagnostico publico em runtime indicando qual caminho foi escolhido. +- Benchmarks locais sao evidencia de uma maquina/rodada, nao promessa publica de performance. diff --git a/.sdd/etapa-7/STATUS.md b/.sdd/etapa-7/STATUS.md index 87ea0e0..c5d75d4 100644 --- a/.sdd/etapa-7/STATUS.md +++ b/.sdd/etapa-7/STATUS.md @@ -62,6 +62,33 @@ Definir a arquitetura e a especificacao inicial para materializacao gerada no Fl - QueryMappedValueObject: 1.355 ms, 587.84 KB. - Executado `dotnet pack .\src\Dapper.FluentMap\Dapper.FluentMap.csproj --configuration Release --no-build --output .\artifacts\packages`: sucesso, gerou `Dapper.FluentMap.2.0.0.nupkg`. - Warnings conhecidos no pack: `NU5125` por `PackageLicenseUrl` legado e recomendacao NuGet para README de pacote. +- Criada a especificacao `.sdd/etapa-7/04-flat-generated-materializers.md`. +- Evoluido `Dapper.FluentMap.Generators` para emitir materializers flat para explicit maps literais simples. +- `AddGeneratedMappings()` agora registra maps/profiles e, quando geravel, registra `AddGeneratedMaterializer(...)` para o shape ordenado canonico. +- Adicionado diagnostic informativo `DFM011` para maps validos que continuam no fallback runtime por feature ainda nao suportada. +- Mantido fallback integral para nested paths, value objects, conventions, `IncludeBase`, shapes ausentes/extras/reordenados e maps dinamicos. +- Adicionados testes do generator para: + - entidade simples; + - colunas renomeadas; + - constructor mapping simples; + - nullable values; + - profiles; + - determinismo existente; + - fallback para nested nao suportado. +- Atualizado teste de integracao de generated registration para validar: + - uso de generated materializer em profile flat; + - constructor mapping gerado; + - nullable values; + - fallback quando o shape da query nao corresponde ao descriptor. +- Atualizados `README.md` e `src/Dapper.FluentMap.Generators/README.md` para documentar materializers flat gerados e fallback. +- Atualizado benchmark steady state para registrar maps via `AddGeneratedMappings()`, exercitando generated materializers nos cenarios flat suportados. +- Adicionada referencia do projeto de benchmarks ao generator como analyzer. +- Executado `dotnet restore .\Dapper.FluentMap.sln`: sucesso. +- Executado `dotnet build .\Dapper.FluentMap.sln --configuration Release --no-restore`: sucesso, 0 warnings, 0 errors. +- Executado `dotnet test .\Dapper.FluentMap.sln --configuration Release --no-build`: sucesso, 244 testes aprovados. +- Executada rodada benchmark steady state `MaterializationSteadyStateBenchmarks`: sucesso. +- Executada rodada benchmark cold start `MaterializationColdStartBenchmarks`: sucesso. +- Atualizada a secao `Apos Prompt 7.4` em `.sdd/etapa-7/02-performance-baseline.md`. ## Em andamento @@ -69,14 +96,12 @@ Nenhum no escopo deste prompt apos o commit local. ## Proximos passos -1. Estender generator para descobrir DSL estatica e emitir descriptors/materializers simples. -2. Prototipar flat/simple generated materialization para explicit maps literais. -3. Repetir benchmarks root/simple apos 7.4. -4. Expandir para nested objects, immutable objects e Value Objects. -5. Repetir benchmarks nested, immutable e Value Object apos 7.5. -6. Adicionar diagnostics de generated/fallback. -7. Repetir todos os benchmarks apos 7.6 para validar lookup generated/fallback integrado. -8. Validar trimming, Native AOT e performance antes de documentar ganhos. +1. Expandir para nested objects, immutable objects compostos e Value Objects. +2. Repetir benchmarks nested, immutable e Value Object apos 7.5. +3. Adicionar diagnostics runtime de generated/fallback. +4. Repetir todos os benchmarks apos 7.6 para validar lookup generated/fallback integrado. +5. Avaliar uma forma segura de medir cold start generated sem expor reset publico desnecessario. +6. Validar trimming, Native AOT e performance antes de documentar ganhos. ## Decisoes relevantes @@ -90,6 +115,7 @@ Nenhum no escopo deste prompt apos o commit local. - Generated materializers usam contrato publico por descriptor e delegate. - Descritores gerados devem ser validados contra o mapping efetivo antes de uso. - `QueryMapped*` mantem annotations de trimming/dynamic-code enquanto houver fallback runtime. +- Prompt 7.4 nao alterou decisoes arquiteturais existentes; apenas implementou a primeira cobertura flat prevista. ## Riscos conhecidos @@ -119,7 +145,8 @@ Nenhum no escopo deste prompt apos o commit local. - `.sdd/etapa-7/02-performance-spec.md` - `.sdd/etapa-7/02-performance-baseline.md` - `.sdd/etapa-7/03-generated-materializer-contracts.md` +- `.sdd/etapa-7/04-flat-generated-materializers.md` ## Ultimo prompt executado -7.3 +7.4 diff --git a/README.md b/README.md index 349600c..aaaccc5 100644 --- a/README.md +++ b/README.md @@ -324,7 +324,7 @@ FluentMapper.Initialize(config => }); ``` -Generated registration calls the existing `AddMap()` / `AddProfile()` paths. It does not generate database materializers, scan referenced assemblies or replace `FluentMapper.Validate()`. +Generated registration calls the existing `AddMap()` / `AddProfile()` paths. For flat explicit maps with literal columns and simple scalar properties, it also registers generated row materializers for the matching ordered column shape. Unsupported maps and unexpected shapes continue to use the runtime fallback. It does not scan referenced assemblies, execute map constructors during generation or replace `FluentMapper.Validate()`. The core runtime also exposes low-level generated materializer registration contracts for generator-emitted code. These contracts are additive infrastructure; current consumers do not need to register materializers manually, and missing generated materializers continue to use the existing runtime fallback. @@ -398,7 +398,7 @@ FluentMapper.Initialize(config => - 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. +- `QueryMapped*` may use generated materializers for supported flat shapes, but it can still fall back to runtime metadata and dynamic code; it is not yet a guaranteed 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. @@ -747,7 +747,7 @@ FluentMapper.Initialize(config => }); ``` -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()`. +O registro gerado chama os caminhos existentes `AddMap()` / `AddProfile()`. Para maps explícitos flat com colunas literais e propriedades escalares simples, ele também registra materializadores de linha gerados para o shape ordenado de colunas correspondente. Maps não suportados e shapes inesperados continuam usando o fallback runtime. Ele não escaneia assemblies referenciados, não executa construtores de maps durante a geração e não substitui `FluentMapper.Validate()`. O runtime principal também expõe contratos de baixo nível para registro de materializadores gerados por código emitido por generator. Esses contratos são infraestrutura aditiva; consumidores atuais não precisam registrar materializadores manualmente, e a ausência de materializadores gerados continua usando o fallback runtime existente. @@ -821,7 +821,7 @@ FluentMapper.Initialize(config => - 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. +- `QueryMapped*` pode usar materializadores gerados para shapes flat suportados, mas ainda pode cair para metadados de runtime e código dinâmico; ele ainda não é um caminho de materialização garantidamente 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. diff --git a/benchmarks/Dapper.FluentMap.Benchmarks/Dapper.FluentMap.Benchmarks.csproj b/benchmarks/Dapper.FluentMap.Benchmarks/Dapper.FluentMap.Benchmarks.csproj index 6276a74..3f1e25a 100644 --- a/benchmarks/Dapper.FluentMap.Benchmarks/Dapper.FluentMap.Benchmarks.csproj +++ b/benchmarks/Dapper.FluentMap.Benchmarks/Dapper.FluentMap.Benchmarks.csproj @@ -2,6 +2,7 @@ + diff --git a/benchmarks/Dapper.FluentMap.Benchmarks/Program.cs b/benchmarks/Dapper.FluentMap.Benchmarks/Program.cs index fae5a49..ffe3ec9 100644 --- a/benchmarks/Dapper.FluentMap.Benchmarks/Program.cs +++ b/benchmarks/Dapper.FluentMap.Benchmarks/Program.cs @@ -38,11 +38,7 @@ public void GlobalSetup() FluentMapper.Initialize(configuration => { - configuration.AddMap(new RootMappedCustomerMap()); - configuration.AddMap(new QueryMappedSimpleCustomerMap()); - configuration.AddMap(new ImmutableCustomerMap()); - configuration.AddMap(new NestedCustomerMap()); - configuration.AddMap(new ValueObjectCustomerMap()); + configuration.AddGeneratedMappings(); }); _connection = OpenPopulatedConnection(); @@ -174,7 +170,7 @@ private static void ResetPublicFluentState() FluentMapper.EntityMaps.Clear(); FluentMapper.TypeConventions.Clear(); - foreach (var type in BenchmarkTypes.AllMappedTypes) + foreach (var type in BenchmarkTypes.AllBenchmarkTypes) { SqlMapper.SetTypeMap(type, null); } @@ -246,7 +242,7 @@ private static void ResetColdPublicState() FluentMapper.EntityMaps.Clear(); FluentMapper.TypeConventions.Clear(); - foreach (var type in BenchmarkTypes.AllColdTypes) + foreach (var type in BenchmarkTypes.AllBenchmarkTypes) { SqlMapper.SetTypeMap(type, null); } @@ -329,6 +325,10 @@ internal static class BenchmarkTypes typeof(ColdNestedCustomer), typeof(ColdValueObjectCustomer) }; + + internal static readonly Type[] AllBenchmarkTypes = AllMappedTypes + .Concat(AllColdTypes) + .ToArray(); } public sealed class PureCustomer diff --git a/src/Dapper.FluentMap.Generators/AnalyzerReleases.Unshipped.md b/src/Dapper.FluentMap.Generators/AnalyzerReleases.Unshipped.md index a58bc16..15b095b 100644 --- a/src/Dapper.FluentMap.Generators/AnalyzerReleases.Unshipped.md +++ b/src/Dapper.FluentMap.Generators/AnalyzerReleases.Unshipped.md @@ -8,3 +8,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 +DFM011 | Dapper.FluentMap.Configuration | Info | Entity map uses runtime materializer fallback for generated materialization diff --git a/src/Dapper.FluentMap.Generators/MappingRegistrationGenerator.cs b/src/Dapper.FluentMap.Generators/MappingRegistrationGenerator.cs index ab9e87e..c52f646 100644 --- a/src/Dapper.FluentMap.Generators/MappingRegistrationGenerator.cs +++ b/src/Dapper.FluentMap.Generators/MappingRegistrationGenerator.cs @@ -17,6 +17,7 @@ public sealed class MappingRegistrationGenerator : IIncrementalGenerator public const string SkippedGeneratedMapDiagnosticId = "DFM006"; public const string DuplicateGeneratedEntityMapDiagnosticId = "DFM007"; public const string DuplicateGeneratedProfileMapDiagnosticId = "DFM008"; + public const string SkippedGeneratedMaterializerDiagnosticId = "DFM011"; private const string Category = "Dapper.FluentMap.Configuration"; private const string MappingNamespace = "Dapper.FluentMap.Mapping"; @@ -58,6 +59,15 @@ public sealed class MappingRegistrationGenerator : IIncrementalGenerator isEnabledByDefault: true, description: "Generated registration must not register more than one map for the same entity and mapping profile."); + private static readonly DiagnosticDescriptor SkippedGeneratedMaterializerRule = new DiagnosticDescriptor( + SkippedGeneratedMaterializerDiagnosticId, + "Generated materializer fallback will be used", + "Entity map type '{0}' is registered, but no flat generated materializer was emitted: {1}", + Category, + DiagnosticSeverity.Info, + isEnabledByDefault: true, + description: "Generated materializers are emitted only for statically known flat explicit mappings. Unsupported mappings continue to use the runtime fallback."); + private static readonly SymbolDisplayFormat FullyQualifiedTypeFormat = new SymbolDisplayFormat( globalNamespaceStyle: SymbolDisplayGlobalNamespaceStyle.Included, typeQualificationStyle: SymbolDisplayTypeQualificationStyle.NameAndContainingTypesAndNamespaces, @@ -143,13 +153,24 @@ private static MapCandidate CreateMapCandidate( return MapCandidate.Skipped(mapDisplayName, location, "the map type does not have a public parameterless constructor"); } + var materializer = TryCreateGeneratedMaterializer( + classDeclaration, + mapType, + entityType, + profileTypeName, + context.SemanticModel, + cancellationToken, + out var materializerSkipReason); + return MapCandidate.Valid( mapDisplayName, mapTypeName, entityType.ToDisplayString(FullyQualifiedTypeFormat), profileTypeName, GetInheritanceDepth(entityType), - location); + location, + materializer, + materializerSkipReason); } private static void Execute( @@ -203,6 +224,16 @@ private static void ReportCandidateDiagnostic(SourceProductionContext context, M candidate.Location, candidate.MapDisplayName, candidate.SkipReason)); + return; + } + + if (candidate.MaterializerSkipReason != null) + { + context.ReportDiagnostic(Diagnostic.Create( + SkippedGeneratedMaterializerRule, + candidate.Location, + candidate.MapDisplayName, + candidate.MaterializerSkipReason)); } } @@ -273,6 +304,12 @@ private static ISet ReportDuplicateProfileMaps( private static string CreateGeneratedSource(IList maps) { + var materializers = maps + .Where(map => map.Materializer != null) + .Select((map, index) => map.Materializer.WithMethodName("Read" + index.ToString(System.Globalization.CultureInfo.InvariantCulture))) + .ToList(); + var materializerByMap = materializers.ToDictionary(materializer => materializer.MapTypeName, StringComparer.Ordinal); + var builder = new StringBuilder(); builder.AppendLine("// "); builder.AppendLine("namespace Dapper.FluentMap"); @@ -298,23 +335,661 @@ private static string CreateGeneratedSource(IList maps) builder.AppendLine(" return configuration"); for (var index = 0; index < maps.Count; index++) { - var terminator = index == maps.Count - 1 ? ";" : string.Empty; - builder.Append(maps[index].ProfileTypeName == null + var map = maps[index]; + var materializerByThisMap = default(GeneratedMaterializerInfo); + materializerByMap.TryGetValue(map.MapTypeName, out materializerByThisMap); + + builder.Append(map.ProfileTypeName == null ? " .AddMap<" : " .AddProfile<"); - builder.Append(maps[index].MapTypeName); - builder.Append(">()"); - builder.AppendLine(terminator); + builder.Append(map.MapTypeName); + builder.AppendLine(">()"); + + if (materializerByThisMap != null) + { + AppendGeneratedMaterializerRegistration(builder, materializerByThisMap); + } + + if (index == maps.Count - 1) + { + builder.AppendLine(" ;"); + } } } builder.AppendLine(" }"); builder.AppendLine(" }"); + + if (materializers.Count > 0) + { + builder.AppendLine(); + builder.AppendLine(" [global::System.CodeDom.Compiler.GeneratedCode(\"Dapper.FluentMap.Generators\", \"2.0.0\")]"); + builder.AppendLine(" internal static class DapperFluentMapGeneratedMaterializers"); + builder.AppendLine(" {"); + + foreach (var materializer in materializers) + { + AppendMaterializerMethod(builder, materializer); + } + + AppendReadHelper(builder); + builder.AppendLine(" }"); + } + builder.AppendLine("}"); return builder.ToString(); } + private static void AppendGeneratedMaterializerRegistration(StringBuilder builder, GeneratedMaterializerInfo materializer) + { + builder.Append(" .AddGeneratedMaterializer<"); + builder.Append(materializer.EntityTypeName); + if (materializer.ProfileTypeName != null) + { + builder.Append(", "); + builder.Append(materializer.ProfileTypeName); + } + + builder.AppendLine(">("); + builder.AppendLine(" new global::Dapper.FluentMap.Materialization.GeneratedMaterializerColumn[]"); + builder.AppendLine(" {"); + + for (var index = 0; index < materializer.Columns.Count; index++) + { + var column = materializer.Columns[index]; + builder.Append(" global::Dapper.FluentMap.Materialization.GeneratedMaterializerColumn."); + builder.Append(column.Ignored ? "Ignore(" : "Map("); + builder.Append(EscapeStringLiteral(column.ColumnName)); + if (!column.Ignored) + { + builder.Append(", "); + builder.Append(EscapeStringLiteral(column.MemberPath)); + } + + builder.Append(index == materializer.Columns.Count - 1 ? ")" : "),"); + builder.AppendLine(); + } + + builder.AppendLine(" },"); + builder.Append(" global::Dapper.FluentMap.DapperFluentMapGeneratedMaterializers."); + builder.Append(materializer.MethodName); + builder.AppendLine(")"); + } + + private static void AppendMaterializerMethod(StringBuilder builder, GeneratedMaterializerInfo materializer) + { + builder.AppendLine(); + builder.Append(" internal static "); + builder.Append(materializer.EntityTypeName); + builder.Append(' '); + builder.Append(materializer.MethodName); + builder.AppendLine("(global::System.Data.IDataRecord record)"); + builder.AppendLine(" {"); + builder.AppendLine(" if (record == null)"); + builder.AppendLine(" {"); + builder.AppendLine(" throw new global::System.ArgumentNullException(nameof(record));"); + builder.AppendLine(" }"); + builder.AppendLine(); + + if (materializer.Constructor == null) + { + builder.Append(" var entity = new "); + builder.Append(materializer.EntityTypeName); + builder.AppendLine("();"); + foreach (var binding in materializer.Bindings.Where(binding => !binding.Ignored)) + { + builder.Append(" entity."); + builder.Append(EscapeIdentifier(binding.PropertyName)); + builder.Append(" = Read<"); + builder.Append(binding.TypeName); + builder.Append(">(record, "); + builder.Append(binding.Ordinal.ToString(System.Globalization.CultureInfo.InvariantCulture)); + builder.AppendLine(");"); + } + + builder.AppendLine(); + builder.AppendLine(" return entity;"); + } + else + { + foreach (var parameter in materializer.Constructor.Parameters) + { + builder.Append(" var "); + builder.Append(EscapeIdentifier(parameter.LocalName)); + builder.Append(" = Read<"); + builder.Append(parameter.TypeName); + builder.Append(">(record, "); + builder.Append(parameter.Ordinal.ToString(System.Globalization.CultureInfo.InvariantCulture)); + builder.AppendLine(");"); + } + + builder.AppendLine(); + builder.AppendLine(" try"); + builder.AppendLine(" {"); + builder.Append(" return new "); + builder.Append(materializer.EntityTypeName); + builder.Append('('); + builder.Append(string.Join(", ", materializer.Constructor.Parameters.Select(parameter => EscapeIdentifier(parameter.LocalName)))); + builder.AppendLine(");"); + builder.AppendLine(" }"); + builder.AppendLine(" catch (global::System.Exception exception)"); + builder.AppendLine(" {"); + builder.Append(" throw new global::Dapper.FluentMap.FluentMapConfigurationException("); + builder.Append(EscapeStringLiteral("Failed to materialize type '" + materializer.EntityTypeName + "' using a generated constructor materializer. See the inner exception for the domain failure.")); + builder.AppendLine(", exception);"); + builder.AppendLine(" }"); + } + + builder.AppendLine(" }"); + } + + private static void AppendReadHelper(StringBuilder builder) + { + builder.AppendLine(); + builder.AppendLine(" private static T Read(global::System.Data.IDataRecord record, int ordinal)"); + builder.AppendLine(" {"); + builder.AppendLine(" if (record.IsDBNull(ordinal))"); + builder.AppendLine(" {"); + builder.AppendLine(" return default(T);"); + builder.AppendLine(" }"); + builder.AppendLine(); + builder.AppendLine(" var value = record.GetValue(ordinal);"); + builder.AppendLine(" if (value is T typedValue)"); + builder.AppendLine(" {"); + builder.AppendLine(" return typedValue;"); + builder.AppendLine(" }"); + builder.AppendLine(); + builder.AppendLine(" var targetType = typeof(T);"); + builder.AppendLine(" var conversionType = global::System.Nullable.GetUnderlyingType(targetType) ?? targetType;"); + builder.AppendLine(" if (conversionType.IsEnum)"); + builder.AppendLine(" {"); + builder.AppendLine(" var enumValue = value is string text"); + builder.AppendLine(" ? global::System.Enum.Parse(conversionType, text)"); + builder.AppendLine(" : global::System.Enum.ToObject(conversionType, value);"); + builder.AppendLine(" return (T)enumValue;"); + builder.AppendLine(" }"); + builder.AppendLine(); + builder.AppendLine(" if (conversionType == typeof(global::System.Guid) && value is string guidText)"); + builder.AppendLine(" {"); + builder.AppendLine(" return (T)(object)new global::System.Guid(guidText);"); + builder.AppendLine(" }"); + builder.AppendLine(); + builder.AppendLine(" return (T)global::System.Convert.ChangeType(value, conversionType, global::System.Globalization.CultureInfo.InvariantCulture);"); + builder.AppendLine(" }"); + } + + private static GeneratedMaterializerInfo TryCreateGeneratedMaterializer( + ClassDeclarationSyntax classDeclaration, + INamedTypeSymbol mapType, + INamedTypeSymbol entityType, + string profileTypeName, + SemanticModel semanticModel, + System.Threading.CancellationToken cancellationToken, + out string skipReason) + { + skipReason = null; + + var constructor = GetPublicParameterlessConstructorDeclaration(classDeclaration, mapType, semanticModel, cancellationToken); + if (constructor == null || constructor.Body == null) + { + return null; + } + + if (ContainsIncludeBaseInvocation(constructor, semanticModel, cancellationToken)) + { + skipReason = "IncludeBase() is not supported by flat generated materializers in this phase"; + return null; + } + + var mapInvocations = new List(); + foreach (var invocation in constructor.Body.DescendantNodes().OfType()) + { + var method = semanticModel.GetSymbolInfo(invocation, cancellationToken).Symbol as IMethodSymbol; + if (!IsMapInvocation(method)) + { + continue; + } + + if (!TryCreateDirectMapInvocation(invocation, semanticModel, cancellationToken, out var mapInvocation, out skipReason)) + { + return null; + } + + mapInvocations.Add(mapInvocation); + } + + if (mapInvocations.Count == 0) + { + return null; + } + + var bindings = new List(); + for (var index = 0; index < mapInvocations.Count; index++) + { + var invocation = mapInvocations[index]; + if (invocation.MemberPath.Properties.Count != 1) + { + skipReason = "nested member paths are handled by the runtime fallback"; + return null; + } + + var property = invocation.MemberPath.Properties[0]; + if (!IsSupportedScalarType(property.Type)) + { + skipReason = $"property '{property.Name}' has type '{FormatSymbol(property.Type)}', which is not supported by flat generated materializers"; + return null; + } + + bindings.Add(new GeneratedPropertyBinding( + index, + invocation.ColumnName, + invocation.MemberPath.Display, + invocation.Ignored, + property.Name, + property.Type.ToDisplayString(FullyQualifiedTypeFormat), + HasPublicSetter(property), + property.Type)); + } + + var materializedBindings = bindings + .Where(binding => !binding.Ignored) + .ToList(); + var constructorBinding = default(GeneratedConstructorBinding); + + if (HasPublicParameterlessConstructor(entityType) && + materializedBindings.All(binding => binding.HasPublicSetter)) + { + constructorBinding = null; + } + else if (!TryCreateConstructorBinding(entityType, materializedBindings, out constructorBinding, out skipReason)) + { + return null; + } + + var columns = bindings + .Select(binding => new GeneratedColumnBinding(binding.ColumnName, binding.MemberPath, binding.Ignored)) + .ToList(); + + return new GeneratedMaterializerInfo( + mapType.ToDisplayString(FullyQualifiedTypeFormat), + entityType.ToDisplayString(FullyQualifiedTypeFormat), + profileTypeName, + columns, + bindings, + constructorBinding, + methodName: null); + } + + private static ConstructorDeclarationSyntax GetPublicParameterlessConstructorDeclaration( + ClassDeclarationSyntax classDeclaration, + INamedTypeSymbol mapType, + SemanticModel semanticModel, + System.Threading.CancellationToken cancellationToken) + { + foreach (var constructor in classDeclaration.Members.OfType()) + { + var symbol = semanticModel.GetDeclaredSymbol(constructor, cancellationToken); + if (symbol != null && + SymbolEqualityComparer.Default.Equals(symbol.ContainingType, mapType) && + symbol.DeclaredAccessibility == Accessibility.Public && + symbol.Parameters.Length == 0) + { + return constructor; + } + } + + return null; + } + + private static bool ContainsIncludeBaseInvocation( + ConstructorDeclarationSyntax constructor, + SemanticModel semanticModel, + System.Threading.CancellationToken cancellationToken) + { + foreach (var invocation in constructor.Body.DescendantNodes().OfType()) + { + var method = semanticModel.GetSymbolInfo(invocation, cancellationToken).Symbol as IMethodSymbol; + if (IsIncludeBaseInvocation(method)) + { + return true; + } + } + + return false; + } + + private static bool TryCreateDirectMapInvocation( + InvocationExpressionSyntax mapInvocation, + SemanticModel semanticModel, + System.Threading.CancellationToken cancellationToken, + out GeneratedMapInvocation result, + out string skipReason) + { + result = null; + skipReason = null; + + if (mapInvocation.ArgumentList.Arguments.Count != 1 || + !TryGetLambda(mapInvocation.ArgumentList.Arguments[0].Expression, out var lambda)) + { + skipReason = "Map(...) invocation is not a statically analyzable lambda expression"; + return false; + } + + if (!TryCreateMemberPath(lambda.Body, semanticModel, cancellationToken, out var memberPath, out skipReason)) + { + return false; + } + + var statement = mapInvocation.FirstAncestorOrSelf(); + if (statement == null) + { + skipReason = "Map(...) invocation is not a direct constructor statement"; + return false; + } + + var column = memberPath.TerminalName; + var ignored = false; + 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 (IsToColumnInvocation(chainedMethod)) + { + if (!TryGetColumn(chainedInvocation, semanticModel, cancellationToken, out column)) + { + skipReason = "ToColumn(...) must use a literal string column name"; + return false; + } + } + else if (IsIgnoreInvocation(chainedMethod)) + { + ignored = true; + } + else + { + skipReason = "the map chain uses an unsupported mapping method"; + return false; + } + + current = chainedInvocation; + } + + if (current != statement.Expression) + { + skipReason = "Map(...) invocation is not a direct constructor statement"; + return false; + } + + result = new GeneratedMapInvocation(memberPath, column, ignored); + return true; + } + + private static bool TryGetColumn( + InvocationExpressionSyntax invocation, + SemanticModel semanticModel, + System.Threading.CancellationToken cancellationToken, + out string column) + { + column = null; + + 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; + 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 GeneratedMemberPath 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 = GeneratedMemberPath.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 TryCreateConstructorBinding( + INamedTypeSymbol entityType, + IList materializedBindings, + out GeneratedConstructorBinding constructorBinding, + out string skipReason) + { + constructorBinding = null; + skipReason = null; + + var candidates = new List(); + foreach (var constructor in entityType.InstanceConstructors + .Where(constructor => constructor.DeclaredAccessibility == Accessibility.Public && !constructor.IsStatic)) + { + if (constructor.Parameters.Length != materializedBindings.Count) + { + continue; + } + + var parameters = new List(); + var usedBindings = new HashSet(); + var failed = false; + + foreach (var parameter in constructor.Parameters) + { + var matches = materializedBindings + .Where(binding => !usedBindings.Contains(binding) && + string.Equals(binding.PropertyName, parameter.Name, StringComparison.OrdinalIgnoreCase) && + IsSameUnwrappedType(binding.PropertyTypeSymbol, parameter.Type)) + .ToList(); + + if (matches.Count != 1) + { + failed = true; + break; + } + + var match = matches[0]; + usedBindings.Add(match); + parameters.Add(new GeneratedConstructorParameter( + CreateUniqueLocalName(parameter.Name, parameters.Count), + parameter.Type.ToDisplayString(FullyQualifiedTypeFormat), + match.Ordinal)); + } + + if (!failed && usedBindings.Count == materializedBindings.Count) + { + candidates.Add(new GeneratedConstructorBinding(parameters)); + } + } + + if (candidates.Count == 1) + { + constructorBinding = candidates[0]; + return true; + } + + skipReason = candidates.Count == 0 + ? "the entity does not have a public parameterless constructor with public setters or a simple public constructor matching all mapped properties" + : "multiple public constructors match all mapped properties"; + return false; + } + + private static bool IsSameUnwrappedType(ITypeSymbol bindingType, ITypeSymbol parameterType) + { + var left = UnwrapNullable(bindingType); + var right = UnwrapNullable(parameterType); + return SymbolEqualityComparer.Default.Equals(left, right); + } + + private static ITypeSymbol UnwrapNullable(ITypeSymbol type) + { + var namedType = type as INamedTypeSymbol; + if (namedType != null && + namedType.ConstructedFrom != null && + namedType.ConstructedFrom.SpecialType == SpecialType.System_Nullable_T && + namedType.TypeArguments.Length == 1) + { + return namedType.TypeArguments[0]; + } + + return type; + } + + private static bool IsSupportedScalarType(ITypeSymbol type) + { + var unwrapped = UnwrapNullable(type); + if (unwrapped.TypeKind == TypeKind.Enum) + { + return true; + } + + switch (unwrapped.SpecialType) + { + case SpecialType.System_Boolean: + case SpecialType.System_Byte: + case SpecialType.System_SByte: + case SpecialType.System_Int16: + case SpecialType.System_UInt16: + case SpecialType.System_Int32: + case SpecialType.System_UInt32: + case SpecialType.System_Int64: + case SpecialType.System_UInt64: + case SpecialType.System_Single: + case SpecialType.System_Double: + case SpecialType.System_Decimal: + case SpecialType.System_Char: + case SpecialType.System_String: + case SpecialType.System_DateTime: + return true; + } + + return IsType(unwrapped as INamedTypeSymbol, "System", "Guid"); + } + + private static bool HasPublicSetter(IPropertySymbol property) + { + return property.SetMethod != null && + property.SetMethod.DeclaredAccessibility == Accessibility.Public && + !property.SetMethod.IsStatic; + } + + private static bool IsMapInvocation(IMethodSymbol method) + { + return method != null && + method.Name == "Map" && + method.Parameters.Length == 1 && + IsType(method.ContainingType.OriginalDefinition, MappingNamespace, "EntityMapBase`2"); + } + + private static bool IsIncludeBaseInvocation(IMethodSymbol method) + { + return method != null && + method.Name == "IncludeBase" && + method.IsGenericMethod && + method.TypeArguments.Length == 1 && + method.Parameters.Length == 0 && + IsType(method.ContainingType.OriginalDefinition, MappingNamespace, "EntityMapBase`2"); + } + + private static bool IsToColumnInvocation(IMethodSymbol method) + { + return method != null && + method.Name == "ToColumn" && + method.Parameters.Length >= 1 && + method.Parameters[0].Type.SpecialType == SpecialType.System_String; + } + + private static bool IsIgnoreInvocation(IMethodSymbol method) + { + return method != null && method.Name == "Ignore" && method.Parameters.Length == 0; + } + private static bool IsEntityMapInterface(INamedTypeSymbol type) { return type.OriginalDefinition.MetadataName == "IEntityMap`1" && @@ -377,11 +1052,69 @@ private static int GetInheritanceDepth(INamedTypeSymbol type) return depth; } + 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 static string EscapeStringLiteral(string value) + { + var builder = new StringBuilder(); + builder.Append('"'); + foreach (var ch in value) + { + switch (ch) + { + case '\\': + builder.Append(@"\\"); + break; + case '"': + builder.Append("\\\""); + break; + case '\r': + builder.Append(@"\r"); + break; + case '\n': + builder.Append(@"\n"); + break; + case '\t': + builder.Append(@"\t"); + break; + default: + builder.Append(ch); + break; + } + } + + builder.Append('"'); + return builder.ToString(); + } + + private static string EscapeIdentifier(string name) + { + return SyntaxFacts.GetKeywordKind(name) != SyntaxKind.None || + SyntaxFacts.GetContextualKeywordKind(name) != SyntaxKind.None + ? "@" + name + : name; + } + + private static string CreateUniqueLocalName(string name, int index) + { + if (string.IsNullOrEmpty(name)) + { + return "arg" + index.ToString(System.Globalization.CultureInfo.InvariantCulture); + } + + return EscapeIdentifier(name); + } + private sealed class MapCandidate { private MapCandidate( @@ -392,7 +1125,9 @@ private MapCandidate( string profileTypeName, int entityInheritanceDepth, Location location, - string skipReason) + string skipReason, + GeneratedMaterializerInfo materializer, + string materializerSkipReason) { Kind = kind; MapDisplayName = mapDisplayName; @@ -402,6 +1137,8 @@ private MapCandidate( EntityInheritanceDepth = entityInheritanceDepth; Location = location; SkipReason = skipReason; + Materializer = materializer; + MaterializerSkipReason = materializerSkipReason; } internal MapCandidateKind Kind { get; } @@ -422,13 +1159,19 @@ private MapCandidate( internal string SkipReason { get; } + internal GeneratedMaterializerInfo Materializer { get; } + + internal string MaterializerSkipReason { get; } + internal static MapCandidate Valid( string mapDisplayName, string mapTypeName, string entityTypeName, string profileTypeName, int entityInheritanceDepth, - Location location) + Location location, + GeneratedMaterializerInfo materializer, + string materializerSkipReason) { return new MapCandidate( MapCandidateKind.Valid, @@ -438,7 +1181,9 @@ internal static MapCandidate Valid( profileTypeName, entityInheritanceDepth, location, - null); + null, + materializer, + materializerSkipReason); } internal static MapCandidate InvalidRegistration(string mapDisplayName, Location location) @@ -451,6 +1196,8 @@ internal static MapCandidate InvalidRegistration(string mapDisplayName, Location null, 0, location, + null, + null, null); } @@ -464,7 +1211,9 @@ internal static MapCandidate Skipped(string mapDisplayName, Location location, s null, 0, location, - reason); + reason, + null, + null); } } @@ -474,5 +1223,174 @@ private enum MapCandidateKind InvalidRegistration, Skipped } + + private sealed class GeneratedMapInvocation + { + internal GeneratedMapInvocation(GeneratedMemberPath memberPath, string columnName, bool ignored) + { + MemberPath = memberPath; + ColumnName = columnName; + Ignored = ignored; + } + + internal GeneratedMemberPath MemberPath { get; } + + internal string ColumnName { get; } + + internal bool Ignored { get; } + } + + private sealed class GeneratedMemberPath + { + private GeneratedMemberPath(IList properties, string display, string terminalName) + { + Properties = properties; + Display = display; + TerminalName = terminalName; + } + + internal IList Properties { get; } + + internal string Display { get; } + + internal string TerminalName { get; } + + internal static GeneratedMemberPath Create(IEnumerable properties) + { + var propertyList = properties.ToList(); + return new GeneratedMemberPath( + propertyList, + string.Join(".", propertyList.Select(property => property.Name)), + propertyList[propertyList.Count - 1].Name); + } + } + + private sealed class GeneratedMaterializerInfo + { + internal GeneratedMaterializerInfo( + string mapTypeName, + string entityTypeName, + string profileTypeName, + IReadOnlyList columns, + IReadOnlyList bindings, + GeneratedConstructorBinding constructor, + string methodName) + { + MapTypeName = mapTypeName; + EntityTypeName = entityTypeName; + ProfileTypeName = profileTypeName; + Columns = columns; + Bindings = bindings; + Constructor = constructor; + MethodName = methodName; + } + + internal string MapTypeName { get; } + + internal string EntityTypeName { get; } + + internal string ProfileTypeName { get; } + + internal IReadOnlyList Columns { get; } + + internal IReadOnlyList Bindings { get; } + + internal GeneratedConstructorBinding Constructor { get; } + + internal string MethodName { get; } + + internal GeneratedMaterializerInfo WithMethodName(string methodName) + { + return new GeneratedMaterializerInfo( + MapTypeName, + EntityTypeName, + ProfileTypeName, + Columns, + Bindings, + Constructor, + methodName); + } + } + + private sealed class GeneratedColumnBinding + { + internal GeneratedColumnBinding(string columnName, string memberPath, bool ignored) + { + ColumnName = columnName; + MemberPath = memberPath; + Ignored = ignored; + } + + internal string ColumnName { get; } + + internal string MemberPath { get; } + + internal bool Ignored { get; } + } + + private sealed class GeneratedPropertyBinding + { + internal GeneratedPropertyBinding( + int ordinal, + string columnName, + string memberPath, + bool ignored, + string propertyName, + string typeName, + bool hasPublicSetter, + ITypeSymbol propertyTypeSymbol) + { + Ordinal = ordinal; + ColumnName = columnName; + MemberPath = memberPath; + Ignored = ignored; + PropertyName = propertyName; + TypeName = typeName; + HasPublicSetter = hasPublicSetter; + PropertyTypeSymbol = propertyTypeSymbol; + } + + internal int Ordinal { get; } + + internal string ColumnName { get; } + + internal string MemberPath { get; } + + internal bool Ignored { get; } + + internal string PropertyName { get; } + + internal string TypeName { get; } + + internal bool HasPublicSetter { get; } + + internal ITypeSymbol PropertyTypeSymbol { get; } + } + + private sealed class GeneratedConstructorBinding + { + internal GeneratedConstructorBinding(IReadOnlyList parameters) + { + Parameters = parameters; + } + + internal IReadOnlyList Parameters { get; } + } + + private sealed class GeneratedConstructorParameter + { + internal GeneratedConstructorParameter(string localName, string typeName, int ordinal) + { + LocalName = localName; + TypeName = typeName; + Ordinal = ordinal; + } + + internal string LocalName { get; } + + internal string TypeName { get; } + + internal int Ordinal { get; } + } } } diff --git a/src/Dapper.FluentMap.Generators/README.md b/src/Dapper.FluentMap.Generators/README.md index e241cbe..3628cd2 100644 --- a/src/Dapper.FluentMap.Generators/README.md +++ b/src/Dapper.FluentMap.Generators/README.md @@ -1,8 +1,8 @@ # Dapper.FluentMap.Generators -Build-time source generator for Dapper.FluentMap mapping registration. +Build-time source generator for Dapper.FluentMap mapping registration and supported flat row materializers. -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. +The generator discovers eligible `IEntityMap` implementations declared in the current compilation and emits an `AddGeneratedMappings()` extension method that registers them through the existing `AddMap()` / `AddProfile()` APIs. For flat explicit maps with literal columns and simple scalar properties, it also registers generated `IDataRecord -> entity` materializers for the matching ordered column shape. ```bash dotnet add package Dapper.FluentMap.Generators @@ -17,4 +17,4 @@ FluentMapper.Initialize(config => }); ``` -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()`. +Generated registration avoids reflection-based assembly scanning for maps in the current compilation. It does not scan referenced assemblies, execute map constructors during generation, parse SQL or replace `FluentMapper.Validate()`. Unsupported maps and unexpected column shapes continue to use the runtime fallback. diff --git a/test/Dapper.FluentMap.GeneratedRegistration.Tests/GeneratedRegistrationIntegrationTests.cs b/test/Dapper.FluentMap.GeneratedRegistration.Tests/GeneratedRegistrationIntegrationTests.cs index 4aaacb1..4c0c906 100644 --- a/test/Dapper.FluentMap.GeneratedRegistration.Tests/GeneratedRegistrationIntegrationTests.cs +++ b/test/Dapper.FluentMap.GeneratedRegistration.Tests/GeneratedRegistrationIntegrationTests.cs @@ -33,6 +33,10 @@ public void GeneratedRegistrationShouldWorkWithDapperAndExistingMappingFeatures( "SELECT 9 AS base_id, 'Lovelace' AS derived_name;"); var immutable = connection.QuerySingle( "SELECT 10 AS immutable_id, 'Grace' AS name;"); + var queryMappedImmutable = connection.QueryMappedSingle( + "SELECT 12 AS immutable_id, 'Generated Constructor' AS name;"); + var nullable = connection.QueryMappedSingle( + "SELECT NULL AS age, NULL AS note;"); var named = connection.QuerySingle( "SELECT '2026-07-26T10:30:00' AS created_at;"); var profiled = connection.QueryMappedSingle( @@ -45,9 +49,21 @@ public void GeneratedRegistrationShouldWorkWithDapperAndExistingMappingFeatures( Assert.Equal("Lovelace", derived.Name); Assert.Equal(10, immutable.Id); Assert.Equal("Grace", immutable.Name); + Assert.Equal(12, queryMappedImmutable.Id); + Assert.Equal("Generated Constructor", queryMappedImmutable.Name); + Assert.Null(nullable.Age); + Assert.Null(nullable.Note); Assert.Equal(new DateTime(2026, 7, 26, 10, 30, 0), named.CreatedAt); Assert.Equal(11, profiled.Id); Assert.Equal("Profiled", profiled.Name); + Assert.Equal(0, FluentMapper.Registry.MaterializationPlanCacheEntryCount); + + var fallback = connection.QueryMappedSingle( + "SELECT 'Fallback' AS Name;"); + + Assert.Equal(0, fallback.Id); + Assert.Equal("Fallback", fallback.Name); + Assert.Equal(1, FluentMapper.Registry.MaterializationPlanCacheEntryCount); } } finally @@ -71,6 +87,7 @@ private static void ResetMapper() typeof(GeneratedBaseCustomer), typeof(GeneratedDerivedCustomer), typeof(GeneratedImmutableCustomer), + typeof(GeneratedNullableCustomer), typeof(GeneratedNamingCustomer), typeof(GeneratedProfileCustomer)); } @@ -153,6 +170,22 @@ public GeneratedImmutableCustomerMap() } } + public sealed class GeneratedNullableCustomer + { + public int? Age { get; set; } + + public string Note { get; set; } + } + + public sealed class GeneratedNullableCustomerMap : EntityMap + { + public GeneratedNullableCustomerMap() + { + Map(customer => customer.Age).ToColumn("age"); + Map(customer => customer.Note).ToColumn("note"); + } + } + public sealed class GeneratedNamingCustomer { public DateTime CreatedAt { get; set; } diff --git a/test/Dapper.FluentMap.Generators.Tests/MappingRegistrationGeneratorTests.cs b/test/Dapper.FluentMap.Generators.Tests/MappingRegistrationGeneratorTests.cs index 71ab96f..a0807b0 100644 --- a/test/Dapper.FluentMap.Generators.Tests/MappingRegistrationGeneratorTests.cs +++ b/test/Dapper.FluentMap.Generators.Tests/MappingRegistrationGeneratorTests.cs @@ -66,6 +66,105 @@ public void Configure() Assert.Empty(result.DfmDiagnostics); Assert.Contains(".AddMap()", result.GeneratedSource, StringComparison.Ordinal); + Assert.Contains(".AddGeneratedMaterializer(", result.GeneratedSource, StringComparison.Ordinal); + Assert.Contains("GeneratedMaterializerColumn.Map(\"customer_id\", \"Id\")", result.GeneratedSource, StringComparison.Ordinal); + Assert.Contains("entity.Id = Read(record, 0);", result.GeneratedSource, StringComparison.Ordinal); + } + + [Fact] + public void RenamedColumnsShouldGenerateFlatMaterializerDescriptor() + { + var source = @" +using Dapper.FluentMap.Mapping; + +public sealed class Customer +{ + public int Id { get; set; } + + public string FullName { get; set; } +} + +public sealed class CustomerMap : EntityMap +{ + public CustomerMap() + { + Map(customer => customer.Id).ToColumn(""customer_id""); + Map(customer => customer.FullName).ToColumn(""full_name""); + } +}"; + + var result = RunGenerator(source); + + Assert.Empty(result.DfmDiagnostics); + Assert.Contains("GeneratedMaterializerColumn.Map(\"customer_id\", \"Id\")", result.GeneratedSource, StringComparison.Ordinal); + Assert.Contains("GeneratedMaterializerColumn.Map(\"full_name\", \"FullName\")", result.GeneratedSource, StringComparison.Ordinal); + Assert.Contains("entity.FullName = Read(record, 1);", result.GeneratedSource, StringComparison.Ordinal); + } + + [Fact] + public void ConstructorMappingShouldGenerateFlatConstructorMaterializer() + { + var source = @" +using Dapper.FluentMap.Mapping; + +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""); + } +}"; + + var result = RunGenerator(source); + + Assert.Empty(result.DfmDiagnostics); + Assert.Contains("var id = Read(record, 0);", result.GeneratedSource, StringComparison.Ordinal); + Assert.Contains("var fullName = Read(record, 1);", result.GeneratedSource, StringComparison.Ordinal); + Assert.Contains("return new global::Customer(id, fullName);", result.GeneratedSource, StringComparison.Ordinal); + Assert.Contains("FluentMapConfigurationException", result.GeneratedSource, StringComparison.Ordinal); + } + + [Fact] + public void NullableValuesShouldUseGeneratedReadHelper() + { + var source = @" +using Dapper.FluentMap.Mapping; + +public sealed class Customer +{ + public int? Age { get; set; } + + public string Note { get; set; } +} + +public sealed class CustomerMap : EntityMap +{ + public CustomerMap() + { + Map(customer => customer.Age).ToColumn(""age""); + Map(customer => customer.Note).ToColumn(""note""); + } +}"; + + var result = RunGenerator(source); + + Assert.Empty(result.DfmDiagnostics); + Assert.Contains("entity.Age = Read(record, 0);", result.GeneratedSource, StringComparison.Ordinal); + Assert.Contains("return default(T);", result.GeneratedSource, StringComparison.Ordinal); } [Fact] @@ -254,6 +353,42 @@ public LegacyCustomerMap() Assert.Empty(result.DfmDiagnostics); Assert.Contains(".AddMap()", result.GeneratedSource, StringComparison.Ordinal); Assert.Contains(".AddProfile()", result.GeneratedSource, StringComparison.Ordinal); + Assert.Contains(".AddGeneratedMaterializer(", result.GeneratedSource, StringComparison.Ordinal); + Assert.Contains(".AddGeneratedMaterializer(", result.GeneratedSource, StringComparison.Ordinal); + } + + [Fact] + public void UnsupportedNestedMappingShouldReportFallbackDiagnostic() + { + var source = @" +using Dapper.FluentMap.Mapping; + +public sealed class Customer +{ + public Address Address { get; set; } +} + +public sealed class Address +{ + public string City { get; set; } +} + +public sealed class CustomerMap : EntityMap +{ + public CustomerMap() + { + Map(customer => customer.Address.City).ToColumn(""city""); + } +}"; + + var result = RunGenerator(source); + var diagnostic = Assert.Single(result.DfmDiagnostics); + + Assert.Equal(MappingRegistrationGenerator.SkippedGeneratedMaterializerDiagnosticId, diagnostic.Id); + Assert.Equal(DiagnosticSeverity.Info, diagnostic.Severity); + Assert.Contains("nested member paths", diagnostic.GetMessage(), StringComparison.Ordinal); + Assert.Contains(".AddMap()", result.GeneratedSource, StringComparison.Ordinal); + Assert.DoesNotContain(".AddGeneratedMaterializer(", result.GeneratedSource, StringComparison.Ordinal); } [Fact] From bef1c46f7131cc50bf24d5f64f38ea35fe70a0e5 Mon Sep 17 00:00:00 2001 From: Rodrigo de Oliveira Date: Tue, 28 Jul 2026 10:25:25 -0300 Subject: [PATCH 05/49] feat(generator): support complex generated materialization --- .sdd/etapa-7/02-performance-baseline.md | 37 + .../05-complex-generated-materialization.md | 198 +++++ .sdd/etapa-7/DECISIONS.md | 69 ++ .sdd/etapa-7/STATUS.md | 41 +- README.md | 8 +- .../MappingRegistrationGenerator.cs | 780 +++++++++++++++--- src/Dapper.FluentMap.Generators/README.md | 4 +- .../GeneratedRegistrationIntegrationTests.cs | 140 +++- .../MappingRegistrationGeneratorTests.cs | 142 +++- 9 files changed, 1286 insertions(+), 133 deletions(-) create mode 100644 .sdd/etapa-7/05-complex-generated-materialization.md diff --git a/.sdd/etapa-7/02-performance-baseline.md b/.sdd/etapa-7/02-performance-baseline.md index fb1bc6d..30ef2fe 100644 --- a/.sdd/etapa-7/02-performance-baseline.md +++ b/.sdd/etapa-7/02-performance-baseline.md @@ -190,3 +190,40 @@ Job: `RunStrategy=ColdStart`, `LaunchCount=8`, `WarmupCount=0`, `IterationCount= - Cold start continuou com variancia alta e outliers. - A rodada cold valida que os cenarios existentes continuam executando apos a integracao do generator no projeto de benchmarks. - Nao ha numero cold dedicado para generated flat neste prompt por causa da limitacao de reset publico descrita acima. + +## Apos Prompt 7.5 + +Prompt 7.5 expandiu os materializers gerados para nested mutable objects, constructor-composed nested immutable objects e Value Objects por componentes. O benchmark steady state existente ja registra maps por `AddGeneratedMappings()`, entao `QueryMappedNestedObject` e `QueryMappedValueObject` passaram a usar generated materializer quando a query retorna o shape canonico gerado. + +### Comando Executado + +Rodada steady state: + +```bash +dotnet run --project .\benchmarks\Dapper.FluentMap.Benchmarks\Dapper.FluentMap.Benchmarks.csproj --configuration Release --no-build -- --filter *MaterializationSteadyStateBenchmarks* +``` + +### Resultados - Steady State + +Job: `ShortRun`, `LaunchCount=1`, `WarmupCount=3`, `IterationCount=3`. + +| Method | Mean | StdDev | Ratio | Gen0 | Gen1 | Allocated | Alloc Ratio | +| --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: | +| DapperPure | 1.295 ms | 0.0535 ms | 1.00 | 68.3594 | - | 283.17 KB | 1.00 | +| QueryMappedValueObject | 1.392 ms | 0.0130 ms | 1.08 | 62.5000 | 19.5313 | 276.47 KB | 0.98 | +| DapperWithFluentMapRootMapping | 1.580 ms | 0.3355 ms | 1.22 | 68.3594 | - | 283.3 KB | 1.00 | +| QueryMappedNestedObject | 1.670 ms | 0.0571 ms | 1.29 | 70.3125 | 21.4844 | 292.44 KB | 1.03 | +| QueryMappedImmutableConstructor | 1.734 ms | 0.0812 ms | 1.34 | 62.5000 | 5.8594 | 261.05 KB | 0.92 | +| QueryMappedSimple | 1.754 ms | 0.1335 ms | 1.36 | 62.5000 | 5.8594 | 261.12 KB | 0.92 | + +### Leitura - Steady State + +- `QueryMappedNestedObject` reduziu alocacao de aproximadamente `377 KB` no Prompt 7.4 para `292.44 KB` por 1000 linhas quando o generated materializer foi usado. +- `QueryMappedValueObject` reduziu alocacao de aproximadamente `587.84 KB` no Prompt 7.4 para `276.47 KB` por 1000 linhas. +- O tempo continua ruidoso em `ShortRun`, especialmente nos cenarios com intervalos amplos; nao ha promessa publica de ganho de tempo. +- A rodada valida que nested e Value Object agora entram no caminho gerado para o shape canonico do benchmark. + +### Limitacoes + +- Nao foi executada rodada cold dedicada para generated complex. O benchmark cold existente registra maps manualmente por `AddMap(...)` e continua representando o fallback/runtime. +- Os resultados continuam locais e devem ser usados como indicio de alocacao, nao como contrato de performance. diff --git a/.sdd/etapa-7/05-complex-generated-materialization.md b/.sdd/etapa-7/05-complex-generated-materialization.md new file mode 100644 index 0000000..6cfa74c --- /dev/null +++ b/.sdd/etapa-7/05-complex-generated-materialization.md @@ -0,0 +1,198 @@ +# Complex Generated Materialization + +Status: SPECIFICATION + IMPLEMENTATION +Prompt: 7.5 +Data: 2026-07-28 + +## Objetivo + +Expandir os materializers gerados alem dos mapas flat do Prompt 7.4 para cobrir object graphs que ja fazem parte do contrato funcional de `QueryMapped*`: + +- nested object paths; +- nested mutable objects; +- nested immutable objects; +- immutable Value Objects por componentes; +- constructor composition; +- null subtree semantics; +- profiles com nested mapping. + +O runtime materializer continua sendo o fallback autoritativo. + +## Construção de Object Graph + +O generator passou a montar uma arvore de metadata por map reconhecido: + +```text +GeneratedMaterializationNode + leaves: colunas escalares materializadas naquele tipo + children: subobjetos por propriedade intermediaria + constructor: plano de construtor quando necessario + post-constructor leaves/children: atribuicoes restantes + subtree ordinals: ordinais usados para null subtree +``` + +Essa representacao evita tratar apenas o nome terminal da propriedade. Paths como `Rank.Level` e `Seniority.Level` viram nodes distintos e descriptors distintos: + +```text +rank_level -> Rank.Level +seniority_level -> Seniority.Level +``` + +O descriptor gerado segue usando `GeneratedMaterializerColumn.Map(column, memberPath)` com o member path completo. + +## Ordem de Criação + +A criacao segue a mesma intencao do runtime: + +1. ler valores escalares necessarios para construtores; +2. construir children usados por construtores, bottom-up; +3. construir o tipo atual por construtor ou construtor sem parametros; +4. aplicar children restantes; +5. aplicar leaves restantes por setters publicos. + +Para objetos mutaveis, o codigo gerado cria ou reutiliza o objeto intermediario quando ha getter publico e construtor sem parametros. Quando nao ha getter publico, cria uma instancia nova e atribui pelo setter publico. + +Para objetos imutaveis e Value Objects, o codigo gerado constroi o child antes de passá-lo ao construtor do parent. + +## Constructor Matching + +O generator seleciona construtores publicos quando a resolucao e deterministica: + +- cada parametro precisa vincular por nome case-insensitive a uma leaf ou child do node atual; +- o tipo do parametro precisa ser compativel com o tipo da propriedade ou child, apos unwrap de `Nullable`; +- membros sem setter publico precisam estar vinculados a construtor; +- children sem setter publico no parent precisam estar vinculados a construtor; +- quando mais de um construtor tem a mesma melhor pontuacao, o materializer gerado nao e emitido. + +Falhas de dominio em construtores gerados sao encapsuladas em `FluentMapConfigurationException` com contexto de tipo, member path e colunas. + +## Null Subtree + +Para cada child node, o codigo gerado testa todos os ordinais da subarvore: + +```text +if any subtree column is non-null: + materialize child +else: + assign null when the parent property has public setter and accepts null +``` + +Isso preserva a semantica do runtime: uma subarvore inteira `NULL` nao cria instancia vazia. Subarvore parcialmente preenchida cria o objeto e aplica `null`/default por leaf conforme a conversao escalar existente. + +## Nullable Columns + +O helper gerado de leitura escalar permanece alinhado ao Prompt 7.4: + +- `DBNull` retorna `default(T)`; +- reference types e `Nullable` recebem `null`; +- value types nao anulaveis recebem `default`; +- enums aceitam texto ou valor numerico; +- `Guid` aceita texto; +- os demais escalares suportados usam `Convert.ChangeType(..., InvariantCulture)`. + +Nullable reference annotations nao mudam a semantica runtime atual; o comportamento continua baseado no tipo CLR observavel. + +## Nested Mutable vs Immutable + +Nested mutable e gerado quando: + +- cada tipo intermediario e classe acessivel pelo codigo gerado; +- o tipo possui construtor publico sem parametros; +- leaves restantes tem setters publicos; +- o parent consegue receber o child por setter publico quando necessario. + +Nested immutable e gerado quando: + +- o child ou parent nao pode ser preenchido por setters; +- existe construtor publico deterministico que vincula leaves ou children por nome/tipo; +- children necessarios sao construidos bottom-up. + +Casos sem construtor deterministico continuam usando fallback runtime. + +## Value Objects + +Value Objects por componentes usam a mesma regra de immutable nested objects. O caso: + +```csharp +Map(customer => customer.Cpf.Number).ToColumn("cpf"); +``` + +gera: + +```text +cpf column -> Cpf(string number) -> Customer(..., Cpf cpf) +``` + +Factory methods continuam fora do contrato desta etapa. Value Object escalar mapeado como propriedade inteira continua sendo melhor atendido por TypeHandler do Dapper e pelo fallback runtime quando necessario. + +## Unsupported Paths + +O generator nao emite materializer quando encontra: + +- `IncludeBase()`; +- conventions ou naming policies como fonte de colunas geradas; +- nomes de coluna nao literais; +- chains de mapping nao reconhecidas; +- collections ou tipos intermediarios nao acessiveis; +- leaf com tipo escalar nao suportado; +- factory methods; +- TypeHandlers no caminho gerado; +- construtores ausentes, incompletos ou ambiguos. + +Esses casos mantem `AddMap()` / `AddProfile()` e recebem diagnostic informativo `DFM011`; a materializacao funcional fica com `NestedMaterializationPlan`. + +## Profiles + +Profiles nested sao gerados com descriptor separado: + +```text +EntityType + ProfileType + ordered ColumnShape +``` + +O profile continua query-scoped por `QueryMapped()` e nao altera o type map global do Dapper. + +## Diagnostics + +`DFM011` continua informativo e indica por que um map registrado nao recebeu materializer gerado. O diagnostic nao transforma fallback em erro. + +O runtime ainda nao expoe diagnostico publico de `Generated` vs `Runtime`; a integracao e testada observando que `MaterializationPlanCacheEntryCount` permanece `0` quando o materializer gerado e usado. + +## Fallback + +Fallback permanece obrigatorio: + +- sem descriptor para entity/profile/shape; +- shape ordenado divergente; +- descriptor incompativel com mapping efetivo; +- feature nao suportada pelo generator; +- configuracao dinamica em runtime. + +O generated path nao muda o comportamento de `Dapper.Query()` nem remove annotations de trimming/dynamic-code de `QueryMapped*`. + +## Testes + +Cobertura adicionada: + +- nested mutable gerado; +- null subtree gerado; +- Value Object por `Cpf.Number`; +- Value Object nullable quando todas as colunas do subtree sao `NULL`; +- dois paths terminando em `Level` (`Rank.Level` e `Seniority.Level`); +- constructor composition root + child; +- constructor incompatível com fallback `DFM011`; +- profile + nested mapping; +- fallback por shape sem descriptor. + +## Benchmarks + +O benchmark steady state existente usa `AddGeneratedMappings()` e, apos este prompt, passa a exercitar generated materializers nos cenarios nested e Value Object. + +Resultados locais foram registrados em `.sdd/etapa-7/02-performance-baseline.md`, secao `Apos Prompt 7.5`. + +## Limitacoes + +- A metadata comum e interna ao generator; runtime e generated ainda nao compartilham uma biblioteca unica de plano. +- A semantica foi mantida alinhada por testes de equivalencia e pelos descriptors validados contra o mapping efetivo. +- TypeHandlers seguem no fallback. +- `IncludeBase()` continua fora do generated materializer. +- Native AOT completo ainda exige validacao dedicada. diff --git a/.sdd/etapa-7/DECISIONS.md b/.sdd/etapa-7/DECISIONS.md index af54310..8e80088 100644 --- a/.sdd/etapa-7/DECISIONS.md +++ b/.sdd/etapa-7/DECISIONS.md @@ -251,3 +251,72 @@ Manter `RequiresUnreferencedCode` e `RequiresDynamicCode` em `QueryMapped*`. - As APIs publicas continuam conservadoras para trimming/AOT. - A reducao futura de warnings exige caminho dedicado ou garantia de generated-only ainda nao especificada. + +## ADR-7.5-001 - Metadata Tree Interna Para Materializers Complexos + +### Contexto + +O Prompt 7.4 gerava materializers flat diretamente a partir da lista de invocacoes `Map(...).ToColumn(...)`. Para nested objects e Value Objects, interpretar somente uma lista de propriedades terminais aumenta o risco de colisao entre paths como `Rank.Level` e `Seniority.Level`. + +### Decisao + +O generator deve construir uma arvore interna de materializacao por map antes de emitir codigo. Cada node representa um tipo no object graph, suas leaves escalares, seus children, seus ordinais de subtree e seu plano de construtor quando necessario. + +### Alternativas + +- Continuar emitindo codigo diretamente da lista flat de bindings. +- Duplicar um algoritmo separado para Value Objects e outro para nested mutable. +- Mover todo o plano runtime para contrato publico compartilhado nesta etapa. + +### Consequencias + +- Member paths completos permanecem distintos. +- Constructor composition pode ser emitida bottom-up. +- Null subtree usa os ordinais do node em vez de inferencias por nome terminal. +- Runtime e generator ainda nao compartilham a mesma implementacao de plano; a equivalencia e protegida por testes e descriptors validados contra o mapping efetivo. + +## ADR-7.5-002 - Generated Complex Mantem Fallback Para Construcao Nao Deterministica + +### Contexto + +O runtime aceita uma gama maior de cenarios porque pode inspecionar metadata em runtime e falhar com diagnostico contextual. O generator nao deve executar constructors de maps nem descobrir factories ou TypeHandlers de forma especulativa. + +### Decisao + +O generated path cobre apenas construtores publicos determinísticos vinculados por nome e tipo a leaves ou children. Factory methods, TypeHandlers, `IncludeBase()`, conventions e construtores ambiguos continuam no fallback runtime com diagnostic informativo `DFM011`. + +### Alternativas + +- Gerar codigo para factories por convencao de nome. +- Tratar fallback como erro de compilacao. +- Ampliar o contrato publico para plugins de construcao nesta etapa. + +### Consequencias + +- A evolucao permanece aditiva e sem breaking change. +- Value Objects por componentes como `Cpf.Number` sao gerados quando o construtor publico e claro. +- Value Objects escalares e cenarios dinamicos seguem suportados pelo runtime. +- Diagnostics continuam informativos, sem impedir build. + +## ADR-7.5-003 - Null Subtree E Decidido Por Ordinais Da Subarvore + +### Contexto + +Nested materialization precisa preservar o comportamento de `QueryMapped*`: quando todas as colunas de uma subarvore sao `NULL`, o objeto intermediario nao deve ser criado. + +### Decisao + +Cada generated node carrega os ordinais materializados da sua subarvore. O codigo emitido testa `record.IsDBNull(...)` para esses ordinais antes de construir children ou Value Objects. + +### Alternativas + +- Criar sempre objetos intermediarios e deixar leaves com `null`/default. +- Testar apenas o primeiro ordinal da subarvore. +- Delegar null subtree para runtime mesmo dentro de materializer gerado. + +### Consequencias + +- Subarvore toda `NULL` vira `null` quando o parent aceita atribuicao. +- Subarvore parcialmente preenchida cria objeto. +- Value Objects usados como argumentos de construtor recebem `null` quando todos os componentes sao `NULL`. +- A semantica fica alinhada ao runtime sem alocar arrays por linha no hot path gerado. diff --git a/.sdd/etapa-7/STATUS.md b/.sdd/etapa-7/STATUS.md index c5d75d4..6f2e0f5 100644 --- a/.sdd/etapa-7/STATUS.md +++ b/.sdd/etapa-7/STATUS.md @@ -89,6 +89,32 @@ Definir a arquitetura e a especificacao inicial para materializacao gerada no Fl - Executada rodada benchmark steady state `MaterializationSteadyStateBenchmarks`: sucesso. - Executada rodada benchmark cold start `MaterializationColdStartBenchmarks`: sucesso. - Atualizada a secao `Apos Prompt 7.4` em `.sdd/etapa-7/02-performance-baseline.md`. +- Criada a especificacao `.sdd/etapa-7/05-complex-generated-materialization.md`. +- Evoluido `Dapper.FluentMap.Generators` para montar uma arvore interna de materializacao gerada. +- Adicionado generated materializer para: + - nested mutable objects; + - nested immutable objects por construtor; + - Value Objects por componentes; + - constructor composition bottom-up; + - null subtree semantics; + - profiles com nested mapping. +- Preservado fallback runtime com `DFM011` para construtores incompatíveis, `IncludeBase`, conventions, TypeHandlers e paths nao determinísticos. +- Adicionados testes do generator para nested mutable, Value Object por constructor composition, paths com mesmo terminal e constructor incompatível com fallback. +- Atualizado teste de integracao de generated registration para validar nested, null subtree, Value Object nullable, `Rank.Level`/`Seniority.Level`, profile nested e fallback por shape sem descriptor. +- Atualizados `README.md` e `src/Dapper.FluentMap.Generators/README.md` para documentar materializers complexos gerados e fallback. +- Executado `dotnet restore .\Dapper.FluentMap.sln`: sucesso. +- Executado `dotnet build .\Dapper.FluentMap.sln --configuration Release --no-restore`: sucesso, 0 warnings, 0 errors. +- Executado `dotnet test .\Dapper.FluentMap.sln --configuration Release --no-build`: sucesso, 247 testes aprovados. +- Executada rodada benchmark steady state `MaterializationSteadyStateBenchmarks`: sucesso. +- Executado `dotnet pack .\src\Dapper.FluentMap.Generators\Dapper.FluentMap.Generators.csproj --configuration Release --no-build --output .\artifacts\packages`: sucesso, gerou `Dapper.FluentMap.Generators.2.0.0.nupkg`. +- Atualizada a secao `Apos Prompt 7.5` em `.sdd/etapa-7/02-performance-baseline.md`. +- Benchmark steady state resumido apos 7.5: + - DapperPure: 1.295 ms, 283.17 KB; + - DapperWithFluentMapRootMapping: 1.580 ms, 283.3 KB; + - QueryMappedSimple: 1.754 ms, 261.12 KB; + - QueryMappedImmutableConstructor: 1.734 ms, 261.05 KB; + - QueryMappedNestedObject: 1.670 ms, 292.44 KB; + - QueryMappedValueObject: 1.392 ms, 276.47 KB. ## Em andamento @@ -96,12 +122,10 @@ Nenhum no escopo deste prompt apos o commit local. ## Proximos passos -1. Expandir para nested objects, immutable objects compostos e Value Objects. -2. Repetir benchmarks nested, immutable e Value Object apos 7.5. -3. Adicionar diagnostics runtime de generated/fallback. -4. Repetir todos os benchmarks apos 7.6 para validar lookup generated/fallback integrado. -5. Avaliar uma forma segura de medir cold start generated sem expor reset publico desnecessario. -6. Validar trimming, Native AOT e performance antes de documentar ganhos. +1. Adicionar diagnostics runtime de generated/fallback. +2. Repetir todos os benchmarks apos 7.6 para validar lookup generated/fallback integrado. +3. Avaliar uma forma segura de medir cold start generated sem expor reset publico desnecessario. +4. Validar trimming, Native AOT e performance antes de documentar ganhos. ## Decisoes relevantes @@ -116,6 +140,8 @@ Nenhum no escopo deste prompt apos o commit local. - Descritores gerados devem ser validados contra o mapping efetivo antes de uso. - `QueryMapped*` mantem annotations de trimming/dynamic-code enquanto houver fallback runtime. - Prompt 7.4 nao alterou decisoes arquiteturais existentes; apenas implementou a primeira cobertura flat prevista. +- Prompt 7.5 usa uma metadata tree interna para preservar member paths completos, null subtree e constructor composition. +- Prompt 7.5 mantem fallback para construcao nao deterministica em vez de gerar codigo especulativo. ## Riscos conhecidos @@ -146,7 +172,8 @@ Nenhum no escopo deste prompt apos o commit local. - `.sdd/etapa-7/02-performance-baseline.md` - `.sdd/etapa-7/03-generated-materializer-contracts.md` - `.sdd/etapa-7/04-flat-generated-materializers.md` +- `.sdd/etapa-7/05-complex-generated-materialization.md` ## Ultimo prompt executado -7.4 +7.5 diff --git a/README.md b/README.md index aaaccc5..3425789 100644 --- a/README.md +++ b/README.md @@ -324,7 +324,7 @@ FluentMapper.Initialize(config => }); ``` -Generated registration calls the existing `AddMap()` / `AddProfile()` paths. For flat explicit maps with literal columns and simple scalar properties, it also registers generated row materializers for the matching ordered column shape. Unsupported maps and unexpected shapes continue to use the runtime fallback. It does not scan referenced assemblies, execute map constructors during generation or replace `FluentMapper.Validate()`. +Generated registration calls the existing `AddMap()` / `AddProfile()` paths. For explicit maps with literal columns and supported deterministic construction, it also registers generated row materializers for the matching ordered column shape, including flat properties, nested object paths and constructor-built Value Objects. Unsupported maps and unexpected shapes continue to use the runtime fallback. It does not scan referenced assemblies, execute map constructors during generation or replace `FluentMapper.Validate()`. The core runtime also exposes low-level generated materializer registration contracts for generator-emitted code. These contracts are additive infrastructure; current consumers do not need to register materializers manually, and missing generated materializers continue to use the existing runtime fallback. @@ -398,7 +398,7 @@ FluentMapper.Initialize(config => - 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*` may use generated materializers for supported flat shapes, but it can still fall back to runtime metadata and dynamic code; it is not yet a guaranteed Native AOT-safe materialization path. +- `QueryMapped*` may use generated materializers for supported flat, nested and Value Object shapes, but it can still fall back to runtime metadata and dynamic code; it is not yet a guaranteed 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. @@ -747,7 +747,7 @@ FluentMapper.Initialize(config => }); ``` -O registro gerado chama os caminhos existentes `AddMap()` / `AddProfile()`. Para maps explícitos flat com colunas literais e propriedades escalares simples, ele também registra materializadores de linha gerados para o shape ordenado de colunas correspondente. Maps não suportados e shapes inesperados continuam usando o fallback runtime. Ele não escaneia assemblies referenciados, não executa construtores de maps durante a geração e não substitui `FluentMapper.Validate()`. +O registro gerado chama os caminhos existentes `AddMap()` / `AddProfile()`. Para maps explícitos com colunas literais e construção determinística suportada, ele também registra materializadores de linha gerados para o shape ordenado de colunas correspondente, incluindo propriedades flat, caminhos aninhados e Value Objects construídos por construtor. Maps não suportados e shapes inesperados continuam usando o fallback runtime. Ele não escaneia assemblies referenciados, não executa construtores de maps durante a geração e não substitui `FluentMapper.Validate()`. O runtime principal também expõe contratos de baixo nível para registro de materializadores gerados por código emitido por generator. Esses contratos são infraestrutura aditiva; consumidores atuais não precisam registrar materializadores manualmente, e a ausência de materializadores gerados continua usando o fallback runtime existente. @@ -821,7 +821,7 @@ FluentMapper.Initialize(config => - 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*` pode usar materializadores gerados para shapes flat suportados, mas ainda pode cair para metadados de runtime e código dinâmico; ele ainda não é um caminho de materialização garantidamente seguro para Native AOT. +- `QueryMapped*` pode usar materializadores gerados para shapes flat, aninhados e Value Object suportados, mas ainda pode cair para metadados de runtime e código dinâmico; ele ainda não é um caminho de materialização garantidamente 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. diff --git a/src/Dapper.FluentMap.Generators/MappingRegistrationGenerator.cs b/src/Dapper.FluentMap.Generators/MappingRegistrationGenerator.cs index c52f646..649388d 100644 --- a/src/Dapper.FluentMap.Generators/MappingRegistrationGenerator.cs +++ b/src/Dapper.FluentMap.Generators/MappingRegistrationGenerator.cs @@ -62,11 +62,11 @@ public sealed class MappingRegistrationGenerator : IIncrementalGenerator private static readonly DiagnosticDescriptor SkippedGeneratedMaterializerRule = new DiagnosticDescriptor( SkippedGeneratedMaterializerDiagnosticId, "Generated materializer fallback will be used", - "Entity map type '{0}' is registered, but no flat generated materializer was emitted: {1}", + "Entity map type '{0}' is registered, but no generated materializer was emitted: {1}", Category, DiagnosticSeverity.Info, isEnabledByDefault: true, - description: "Generated materializers are emitted only for statically known flat explicit mappings. Unsupported mappings continue to use the runtime fallback."); + description: "Generated materializers are emitted only for statically known explicit mappings with supported object construction. Unsupported mappings continue to use the runtime fallback."); private static readonly SymbolDisplayFormat FullyQualifiedTypeFormat = new SymbolDisplayFormat( globalNamespaceStyle: SymbolDisplayGlobalNamespaceStyle.Included, @@ -432,56 +432,275 @@ private static void AppendMaterializerMethod(StringBuilder builder, GeneratedMat builder.AppendLine(" }"); builder.AppendLine(); - if (materializer.Constructor == null) + AppendMaterializeNode(builder, materializer.Root, "entity", " ", null, materializer.EntityTypeName, localAlreadyDeclared: false); + builder.AppendLine(); + builder.AppendLine(" return entity;"); + + builder.AppendLine(" }"); + } + + private static void AppendMaterializeNode( + StringBuilder builder, + GeneratedMaterializationNode node, + string localName, + string indent, + string parentLocalName, + string entityTypeName, + bool localAlreadyDeclared) + { + if (node.Constructor == null) { - builder.Append(" var entity = new "); - builder.Append(materializer.EntityTypeName); - builder.AppendLine("();"); - foreach (var binding in materializer.Bindings.Where(binding => !binding.Ignored)) + AppendCreateParameterlessNode(builder, node, localName, indent, parentLocalName, localAlreadyDeclared); + } + else + { + AppendCreateConstructorNode(builder, node, localName, indent, entityTypeName, localAlreadyDeclared); + } + + foreach (var child in node.PostConstructorChildren) + { + AppendApplyChild(builder, child, localName, indent, entityTypeName); + } + + foreach (var leaf in node.PostConstructorLeaves) + { + AppendAssignLeaf(builder, leaf, localName, indent); + } + } + + private static void AppendCreateParameterlessNode( + StringBuilder builder, + GeneratedMaterializationNode node, + string localName, + string indent, + string parentLocalName, + bool localAlreadyDeclared) + { + if (node.IsRoot || parentLocalName == null || !node.HasPublicGetter) + { + builder.Append(indent); + if (!localAlreadyDeclared) { - builder.Append(" entity."); - builder.Append(EscapeIdentifier(binding.PropertyName)); - builder.Append(" = Read<"); - builder.Append(binding.TypeName); - builder.Append(">(record, "); - builder.Append(binding.Ordinal.ToString(System.Globalization.CultureInfo.InvariantCulture)); - builder.AppendLine(");"); + builder.Append("var "); } - builder.AppendLine(); - builder.AppendLine(" return entity;"); + builder.Append(localName); + builder.Append(" = new "); + builder.Append(node.TypeName); + builder.AppendLine("();"); + return; } - else + + builder.Append(indent); + builder.Append("var "); + builder.Append(localName); + builder.Append(" = "); + builder.Append(parentLocalName); + builder.Append('.'); + builder.Append(EscapeIdentifier(node.PropertyName)); + builder.AppendLine(";"); + builder.Append(indent); + builder.Append("if ("); + builder.Append(localName); + builder.AppendLine(" == null)"); + builder.Append(indent); + builder.AppendLine("{"); + builder.Append(indent); + builder.Append(" "); + builder.Append(localName); + builder.Append(" = new "); + builder.Append(node.TypeName); + builder.AppendLine("();"); + if (node.HasPublicSetter) { - foreach (var parameter in materializer.Constructor.Parameters) + builder.Append(indent); + builder.Append(" "); + builder.Append(parentLocalName); + builder.Append('.'); + builder.Append(EscapeIdentifier(node.PropertyName)); + builder.Append(" = "); + builder.Append(localName); + builder.AppendLine(";"); + } + + builder.Append(indent); + builder.AppendLine("}"); + } + + private static void AppendCreateConstructorNode( + StringBuilder builder, + GeneratedMaterializationNode node, + string localName, + string indent, + string entityTypeName, + bool localAlreadyDeclared) + { + foreach (var parameter in node.Constructor.Parameters) + { + if (parameter.Leaf != null) { - builder.Append(" var "); - builder.Append(EscapeIdentifier(parameter.LocalName)); + builder.Append(indent); + builder.Append("var "); + builder.Append(parameter.LocalName); builder.Append(" = Read<"); builder.Append(parameter.TypeName); builder.Append(">(record, "); - builder.Append(parameter.Ordinal.ToString(System.Globalization.CultureInfo.InvariantCulture)); + builder.Append(parameter.Leaf.Ordinal.ToString(System.Globalization.CultureInfo.InvariantCulture)); builder.AppendLine(");"); + continue; } - builder.AppendLine(); - builder.AppendLine(" try"); - builder.AppendLine(" {"); - builder.Append(" return new "); - builder.Append(materializer.EntityTypeName); - builder.Append('('); - builder.Append(string.Join(", ", materializer.Constructor.Parameters.Select(parameter => EscapeIdentifier(parameter.LocalName)))); - builder.AppendLine(");"); - builder.AppendLine(" }"); - builder.AppendLine(" catch (global::System.Exception exception)"); - builder.AppendLine(" {"); - builder.Append(" throw new global::Dapper.FluentMap.FluentMapConfigurationException("); - builder.Append(EscapeStringLiteral("Failed to materialize type '" + materializer.EntityTypeName + "' using a generated constructor materializer. See the inner exception for the domain failure.")); - builder.AppendLine(", exception);"); - builder.AppendLine(" }"); + AppendCreateChildValue(builder, parameter.Child, parameter.LocalName, indent, entityTypeName); } - builder.AppendLine(" }"); + builder.AppendLine(); + if (!localAlreadyDeclared) + { + builder.Append(indent); + builder.Append(node.TypeName); + builder.Append(' '); + builder.Append(localName); + builder.AppendLine(";"); + } + + builder.Append(indent); + builder.AppendLine("try"); + builder.Append(indent); + builder.AppendLine("{"); + builder.Append(indent); + builder.Append(" "); + builder.Append(localName); + builder.Append(" = new "); + builder.Append(node.TypeName); + builder.Append('('); + builder.Append(string.Join(", ", node.Constructor.Parameters.Select(parameter => parameter.LocalName))); + builder.AppendLine(");"); + builder.Append(indent); + builder.AppendLine("}"); + builder.Append(indent); + builder.AppendLine("catch (global::System.Exception exception)"); + builder.Append(indent); + builder.AppendLine("{"); + builder.Append(indent); + builder.Append(" throw new global::Dapper.FluentMap.FluentMapConfigurationException("); + builder.Append(EscapeStringLiteral( + "Failed to materialize type '" + node.TypeName + "' at member path '" + node.MemberPath + "' on entity '" + entityTypeName + "' using generated constructor materializer. Columns: " + FormatGeneratedColumns(node) + ". See the inner exception for the domain failure.")); + builder.AppendLine(", exception);"); + builder.Append(indent); + builder.AppendLine("}"); + } + + private static void AppendApplyChild( + StringBuilder builder, + GeneratedMaterializationNode child, + string parentLocalName, + string indent, + string entityTypeName) + { + builder.Append(indent); + builder.Append("if ("); + AppendHasAnyValueExpression(builder, child); + builder.AppendLine(")"); + builder.Append(indent); + builder.AppendLine("{"); + var childLocalName = "node" + child.Id.ToString(System.Globalization.CultureInfo.InvariantCulture); + AppendMaterializeNode(builder, child, childLocalName, indent + " ", parentLocalName, entityTypeName, localAlreadyDeclared: false); + if (child.HasPublicSetter && (child.Constructor != null || !child.HasPublicGetter)) + { + builder.Append(indent); + builder.Append(" "); + builder.Append(parentLocalName); + builder.Append('.'); + builder.Append(EscapeIdentifier(child.PropertyName)); + builder.Append(" = "); + builder.Append(childLocalName); + builder.AppendLine(";"); + } + + builder.Append(indent); + builder.AppendLine("}"); + if (child.HasPublicSetter && child.CanAssignNull) + { + builder.Append(indent); + builder.AppendLine("else"); + builder.Append(indent); + builder.AppendLine("{"); + builder.Append(indent); + builder.Append(" "); + builder.Append(parentLocalName); + builder.Append('.'); + builder.Append(EscapeIdentifier(child.PropertyName)); + builder.AppendLine(" = null;"); + builder.Append(indent); + builder.AppendLine("}"); + } + } + + private static void AppendCreateChildValue( + StringBuilder builder, + GeneratedMaterializationNode child, + string localName, + string indent, + string entityTypeName) + { + builder.Append(indent); + builder.Append(child.TypeName); + builder.Append(' '); + builder.Append(localName); + builder.AppendLine(" = null;"); + builder.Append(indent); + builder.Append("if ("); + AppendHasAnyValueExpression(builder, child); + builder.AppendLine(")"); + builder.Append(indent); + builder.AppendLine("{"); + AppendMaterializeNode(builder, child, localName, indent + " ", null, entityTypeName, localAlreadyDeclared: true); + builder.Append(indent); + builder.AppendLine("}"); + } + + private static void AppendAssignLeaf( + StringBuilder builder, + GeneratedPropertyBinding leaf, + string targetLocalName, + string indent) + { + builder.Append(indent); + builder.Append(targetLocalName); + builder.Append('.'); + builder.Append(EscapeIdentifier(leaf.PropertyName)); + builder.Append(" = Read<"); + builder.Append(leaf.TypeName); + builder.Append(">(record, "); + builder.Append(leaf.Ordinal.ToString(System.Globalization.CultureInfo.InvariantCulture)); + builder.AppendLine(");"); + } + + private static void AppendHasAnyValueExpression(StringBuilder builder, GeneratedMaterializationNode node) + { + var ordinals = node.SubtreeOrdinals.ToList(); + if (ordinals.Count == 0) + { + builder.Append("false"); + return; + } + + for (var index = 0; index < ordinals.Count; index++) + { + if (index > 0) + { + builder.Append(" || "); + } + + builder.Append("!record.IsDBNull("); + builder.Append(ordinals[index].ToString(System.Globalization.CultureInfo.InvariantCulture)); + builder.Append(')'); + } + } + + private static string FormatGeneratedColumns(GeneratedMaterializationNode node) + { + return string.Join(", ", node.GetColumnNames().Select(column => "'" + column + "'")); } private static void AppendReadHelper(StringBuilder builder) @@ -538,7 +757,7 @@ private static GeneratedMaterializerInfo TryCreateGeneratedMaterializer( if (ContainsIncludeBaseInvocation(constructor, semanticModel, cancellationToken)) { - skipReason = "IncludeBase() is not supported by flat generated materializers in this phase"; + skipReason = "IncludeBase() is not supported by generated materializers in this phase"; return null; } @@ -564,63 +783,93 @@ private static GeneratedMaterializerInfo TryCreateGeneratedMaterializer( return null; } - var bindings = new List(); + var columns = new List(); + var root = new GeneratedMaterializationNode( + id: 0, + type: entityType, + parentProperty: null, + memberPath: entityType.Name, + isRoot: true); + + var nextNodeId = 1; for (var index = 0; index < mapInvocations.Count; index++) { var invocation = mapInvocations[index]; - if (invocation.MemberPath.Properties.Count != 1) + columns.Add(new GeneratedColumnBinding( + invocation.ColumnName, + invocation.MemberPath.Display, + invocation.Ignored)); + + if (invocation.Ignored) { - skipReason = "nested member paths are handled by the runtime fallback"; - return null; + continue; } - var property = invocation.MemberPath.Properties[0]; - if (!IsSupportedScalarType(property.Type)) + if (!TryAddMaterializedPath(root, invocation, index, ref nextNodeId, out skipReason)) { - skipReason = $"property '{property.Name}' has type '{FormatSymbol(property.Type)}', which is not supported by flat generated materializers"; return null; } - - bindings.Add(new GeneratedPropertyBinding( - index, - invocation.ColumnName, - invocation.MemberPath.Display, - invocation.Ignored, - property.Name, - property.Type.ToDisplayString(FullyQualifiedTypeFormat), - HasPublicSetter(property), - property.Type)); } - var materializedBindings = bindings - .Where(binding => !binding.Ignored) - .ToList(); - var constructorBinding = default(GeneratedConstructorBinding); - - if (HasPublicParameterlessConstructor(entityType) && - materializedBindings.All(binding => binding.HasPublicSetter)) - { - constructorBinding = null; - } - else if (!TryCreateConstructorBinding(entityType, materializedBindings, out constructorBinding, out skipReason)) + if (!root.Seal(entityType, out skipReason)) { return null; } - var columns = bindings - .Select(binding => new GeneratedColumnBinding(binding.ColumnName, binding.MemberPath, binding.Ignored)) - .ToList(); - return new GeneratedMaterializerInfo( mapType.ToDisplayString(FullyQualifiedTypeFormat), entityType.ToDisplayString(FullyQualifiedTypeFormat), profileTypeName, columns, - bindings, - constructorBinding, + root, methodName: null); } + private static bool TryAddMaterializedPath( + GeneratedMaterializationNode root, + GeneratedMapInvocation invocation, + int ordinal, + ref int nextNodeId, + out string skipReason) + { + skipReason = null; + + var properties = invocation.MemberPath.Properties; + for (var index = 0; index < properties.Count - 1; index++) + { + var property = properties[index]; + if (!IsSupportedComplexType(property.Type)) + { + skipReason = $"nested property '{property.Name}' has type '{FormatSymbol(property.Type)}', which is not supported by generated materializers"; + return false; + } + } + + var leaf = properties[properties.Count - 1]; + if (!IsSupportedScalarType(leaf.Type)) + { + skipReason = $"property '{invocation.MemberPath.Display}' has type '{FormatSymbol(leaf.Type)}', which is not supported by generated materializers"; + return false; + } + + var node = root; + for (var index = 0; index < properties.Count - 1; index++) + { + node = node.FindOrAddChild(properties[index], ref nextNodeId); + } + + node.AddLeaf(new GeneratedPropertyBinding( + ordinal, + invocation.ColumnName, + invocation.MemberPath.Display, + leaf.Name, + leaf.Type.ToDisplayString(FullyQualifiedTypeFormat), + HasPublicSetter(leaf), + leaf.Type)); + + return true; + } + private static ConstructorDeclarationSyntax GetPublicParameterlessConstructorDeclaration( ClassDeclarationSyntax classDeclaration, INamedTypeSymbol mapType, @@ -840,7 +1089,7 @@ private static ExpressionSyntax StripCastsAndParentheses(ExpressionSyntax expres private static bool TryCreateConstructorBinding( INamedTypeSymbol entityType, - IList materializedBindings, + GeneratedMaterializationNode node, out GeneratedConstructorBinding constructorBinding, out string skipReason) { @@ -848,43 +1097,39 @@ private static bool TryCreateConstructorBinding( skipReason = null; var candidates = new List(); - foreach (var constructor in entityType.InstanceConstructors + var nodeType = (INamedTypeSymbol)node.Type; + foreach (var constructor in nodeType.InstanceConstructors .Where(constructor => constructor.DeclaredAccessibility == Accessibility.Public && !constructor.IsStatic)) { - if (constructor.Parameters.Length != materializedBindings.Count) - { - continue; - } - var parameters = new List(); - var usedBindings = new HashSet(); var failed = false; + var score = 0; foreach (var parameter in constructor.Parameters) { - var matches = materializedBindings - .Where(binding => !usedBindings.Contains(binding) && - string.Equals(binding.PropertyName, parameter.Name, StringComparison.OrdinalIgnoreCase) && - IsSameUnwrappedType(binding.PropertyTypeSymbol, parameter.Type)) - .ToList(); - - if (matches.Count != 1) + var parameterBinding = TryBindConstructorParameter(node, parameter); + if (parameterBinding == null) { failed = true; break; } - var match = matches[0]; - usedBindings.Add(match); + var localName = node.IsRoot + ? CreateUniqueLocalName(parameter.Name, parameters.Count) + : "arg" + node.Id.ToString(System.Globalization.CultureInfo.InvariantCulture) + "_" + parameters.Count.ToString(System.Globalization.CultureInfo.InvariantCulture); parameters.Add(new GeneratedConstructorParameter( - CreateUniqueLocalName(parameter.Name, parameters.Count), + localName, parameter.Type.ToDisplayString(FullyQualifiedTypeFormat), - match.Ordinal)); + parameterBinding.Leaf, + parameterBinding.Child)); + score += parameterBinding.Score; } - if (!failed && usedBindings.Count == materializedBindings.Count) + if (!failed && + node.Leaves.Where(leaf => !leaf.CanAssign).All(leaf => parameters.Any(parameter => parameter.Leaf == leaf)) && + node.Children.Where(child => !child.CanAssignToParent).All(child => parameters.Any(parameter => parameter.Child == child))) { - candidates.Add(new GeneratedConstructorBinding(parameters)); + candidates.Add(new GeneratedConstructorBinding(constructor, parameters, score)); } } @@ -894,17 +1139,90 @@ private static bool TryCreateConstructorBinding( return true; } + if (candidates.Count > 1) + { + var bestScore = candidates.Max(candidate => candidate.Score); + var best = candidates.Where(candidate => candidate.Score == bestScore).ToList(); + if (best.Count == 1) + { + constructorBinding = best[0]; + return true; + } + } + skipReason = candidates.Count == 0 - ? "the entity does not have a public parameterless constructor with public setters or a simple public constructor matching all mapped properties" - : "multiple public constructors match all mapped properties"; + ? $"type '{FormatSymbol(node.Type)}' at member path '{node.MemberPath}' does not have a supported public constructor for generated materialization" + : $"type '{FormatSymbol(node.Type)}' at member path '{node.MemberPath}' has multiple public constructors that match generated materialization"; return false; } - private static bool IsSameUnwrappedType(ITypeSymbol bindingType, ITypeSymbol parameterType) + private static GeneratedParameterMatch TryBindConstructorParameter( + GeneratedMaterializationNode node, + IParameterSymbol parameter) { - var left = UnwrapNullable(bindingType); - var right = UnwrapNullable(parameterType); - return SymbolEqualityComparer.Default.Equals(left, right); + var matches = node.Leaves + .Where(leaf => string.Equals(leaf.PropertyName, parameter.Name, StringComparison.OrdinalIgnoreCase) && + IsParameterCompatible(parameter.Type, leaf.PropertyTypeSymbol)) + .Select(leaf => GeneratedParameterMatch.ForLeaf( + leaf, + GetCompatibilityScore(parameter.Type, leaf.PropertyTypeSymbol))) + .Concat(node.Children + .Where(child => string.Equals(child.PropertyName, parameter.Name, StringComparison.OrdinalIgnoreCase) && + IsParameterCompatible(parameter.Type, child.PropertyTypeSymbol)) + .Select(child => GeneratedParameterMatch.ForChild( + child, + GetCompatibilityScore(parameter.Type, child.PropertyTypeSymbol)))) + .OrderByDescending(match => match.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 static bool IsParameterCompatible(ITypeSymbol parameterType, ITypeSymbol sourceType) + { + var parameter = UnwrapNullable(parameterType); + var source = UnwrapNullable(sourceType); + return IsAssignableFrom(parameter, source) || IsAssignableFrom(source, parameter); + } + + private static int GetCompatibilityScore(ITypeSymbol parameterType, ITypeSymbol sourceType) + { + var parameter = UnwrapNullable(parameterType); + var source = UnwrapNullable(sourceType); + return SymbolEqualityComparer.Default.Equals(parameter, source) ? 2 : 1; + } + + private static bool IsAssignableFrom(ITypeSymbol targetType, ITypeSymbol sourceType) + { + if (SymbolEqualityComparer.Default.Equals(targetType, sourceType)) + { + return true; + } + + for (var current = sourceType; current != null; current = current.BaseType) + { + if (SymbolEqualityComparer.Default.Equals(targetType, current)) + { + return true; + } + } + + foreach (var interfaceType in sourceType.AllInterfaces) + { + if (SymbolEqualityComparer.Default.Equals(targetType, interfaceType)) + { + return true; + } + } + + return false; } private static ITypeSymbol UnwrapNullable(ITypeSymbol type) @@ -952,6 +1270,31 @@ private static bool IsSupportedScalarType(ITypeSymbol type) return IsType(unwrapped as INamedTypeSymbol, "System", "Guid"); } + private static bool IsSupportedComplexType(ITypeSymbol type) + { + var unwrapped = UnwrapNullable(type); + var namedType = unwrapped as INamedTypeSymbol; + return namedType != null && + namedType.TypeKind == TypeKind.Class && + IsAccessibleFromGeneratedCode(namedType); + } + + private static bool CanAssignNull(ITypeSymbol type) + { + var namedType = type as INamedTypeSymbol; + return type.IsReferenceType || + namedType != null && + namedType.ConstructedFrom != null && + namedType.ConstructedFrom.SpecialType == SpecialType.System_Nullable_T; + } + + private static bool HasPublicGetter(IPropertySymbol property) + { + return property.GetMethod != null && + property.GetMethod.DeclaredAccessibility == Accessibility.Public && + !property.GetMethod.IsStatic; + } + private static bool HasPublicSetter(IPropertySymbol property) { return property.SetMethod != null && @@ -1272,16 +1615,14 @@ internal GeneratedMaterializerInfo( string entityTypeName, string profileTypeName, IReadOnlyList columns, - IReadOnlyList bindings, - GeneratedConstructorBinding constructor, + GeneratedMaterializationNode root, string methodName) { MapTypeName = mapTypeName; EntityTypeName = entityTypeName; ProfileTypeName = profileTypeName; Columns = columns; - Bindings = bindings; - Constructor = constructor; + Root = root; MethodName = methodName; } @@ -1293,9 +1634,7 @@ internal GeneratedMaterializerInfo( internal IReadOnlyList Columns { get; } - internal IReadOnlyList Bindings { get; } - - internal GeneratedConstructorBinding Constructor { get; } + internal GeneratedMaterializationNode Root { get; } internal string MethodName { get; } @@ -1306,8 +1645,7 @@ internal GeneratedMaterializerInfo WithMethodName(string methodName) EntityTypeName, ProfileTypeName, Columns, - Bindings, - Constructor, + Root, methodName); } } @@ -1334,7 +1672,6 @@ internal GeneratedPropertyBinding( int ordinal, string columnName, string memberPath, - bool ignored, string propertyName, string typeName, bool hasPublicSetter, @@ -1343,7 +1680,6 @@ internal GeneratedPropertyBinding( Ordinal = ordinal; ColumnName = columnName; MemberPath = memberPath; - Ignored = ignored; PropertyName = propertyName; TypeName = typeName; HasPublicSetter = hasPublicSetter; @@ -1356,41 +1692,253 @@ internal GeneratedPropertyBinding( internal string MemberPath { get; } - internal bool Ignored { get; } - internal string PropertyName { get; } internal string TypeName { get; } internal bool HasPublicSetter { get; } + internal bool CanAssign => HasPublicSetter; + internal ITypeSymbol PropertyTypeSymbol { get; } } + private sealed class GeneratedMaterializationNode + { + private readonly List _leaves = new List(); + private readonly List _children = new List(); + + internal GeneratedMaterializationNode( + int id, + ITypeSymbol type, + IPropertySymbol parentProperty, + string memberPath, + bool isRoot) + { + Id = id; + Type = type; + TypeName = type.ToDisplayString(FullyQualifiedTypeFormat); + ParentProperty = parentProperty; + PropertyName = parentProperty == null ? null : parentProperty.Name; + PropertyTypeSymbol = parentProperty == null ? type : parentProperty.Type; + MemberPath = memberPath; + IsRoot = isRoot; + HasPublicGetter = parentProperty != null && MappingRegistrationGenerator.HasPublicGetter(parentProperty); + HasPublicSetter = parentProperty != null && MappingRegistrationGenerator.HasPublicSetter(parentProperty); + CanAssignNull = parentProperty == null || MappingRegistrationGenerator.CanAssignNull(parentProperty.Type); + } + + internal int Id { get; } + + internal ITypeSymbol Type { get; } + + internal string TypeName { get; } + + internal IPropertySymbol ParentProperty { get; } + + internal string PropertyName { get; } + + internal ITypeSymbol PropertyTypeSymbol { get; } + + internal string MemberPath { get; } + + internal bool IsRoot { get; } + + internal bool HasPublicGetter { get; } + + internal bool HasPublicSetter { get; } + + internal bool CanAssignNull { get; } + + internal bool CanAssignToParent => IsRoot || HasPublicSetter; + + internal IReadOnlyList Leaves => _leaves; + + internal IReadOnlyList Children => _children; + + internal GeneratedConstructorBinding Constructor { get; private set; } + + internal IReadOnlyList PostConstructorLeaves { get; private set; } + + internal IReadOnlyList PostConstructorChildren { get; private set; } + + internal IReadOnlyList SubtreeOrdinals { get; private set; } + + internal GeneratedMaterializationNode FindOrAddChild(IPropertySymbol property, ref int nextNodeId) + { + var existing = _children.FirstOrDefault(candidate => SymbolEqualityComparer.Default.Equals(candidate.ParentProperty, property)); + if (existing != null) + { + return existing; + } + + var memberPath = IsRoot + ? property.Name + : MemberPath + "." + property.Name; + var createdChild = new GeneratedMaterializationNode(nextNodeId++, property.Type, property, memberPath, isRoot: false); + _children.Add(createdChild); + return createdChild; + } + + internal void AddLeaf(GeneratedPropertyBinding leaf) + { + _leaves.Add(leaf); + } + + internal bool Seal(INamedTypeSymbol entityType, out string skipReason) + { + foreach (var child in _children) + { + if (!child.Seal(entityType, out skipReason)) + { + return false; + } + } + + SubtreeOrdinals = _leaves + .Select(leaf => leaf.Ordinal) + .Concat(_children.SelectMany(child => child.SubtreeOrdinals)) + .Distinct() + .ToArray(); + + var hasParameterlessConstructor = Type is INamedTypeSymbol namedType && + MappingRegistrationGenerator.HasPublicParameterlessConstructor(namedType); + var requiresConstructor = !hasParameterlessConstructor || + _leaves.Any(leaf => !leaf.CanAssign) || + _children.Any(child => !child.CanAssignToParent); + + if (!requiresConstructor) + { + Constructor = null; + PostConstructorLeaves = _leaves.ToArray(); + PostConstructorChildren = _children.ToArray(); + skipReason = null; + return true; + } + + if (!(Type is INamedTypeSymbol)) + { + skipReason = $"type '{FormatSymbol(Type)}' at member path '{MemberPath}' is not supported by generated constructor materialization"; + return false; + } + + if (!TryCreateConstructorBinding(entityType, this, out var constructor, out skipReason)) + { + return false; + } + + Constructor = constructor; + PostConstructorLeaves = _leaves + .Where(leaf => !constructor.Uses(leaf)) + .ToArray(); + PostConstructorChildren = _children + .Where(child => !constructor.Uses(child)) + .ToArray(); + + var unsupportedLeaf = PostConstructorLeaves.FirstOrDefault(leaf => !leaf.CanAssign); + if (unsupportedLeaf != null) + { + skipReason = $"type '{FormatSymbol(Type)}' at member path '{MemberPath}' cannot assign mapped property '{unsupportedLeaf.MemberPath}' in generated materialization"; + return false; + } + + var unsupportedChild = PostConstructorChildren.FirstOrDefault(child => !child.CanAssignToParent); + if (unsupportedChild != null) + { + skipReason = $"type '{FormatSymbol(Type)}' at member path '{MemberPath}' cannot assign nested object '{unsupportedChild.MemberPath}' in generated materialization"; + return false; + } + + skipReason = null; + return true; + } + + internal IEnumerable GetColumnNames() + { + return _leaves.Select(leaf => leaf.ColumnName) + .Concat(_children.SelectMany(child => child.GetColumnNames())); + } + } + private sealed class GeneratedConstructorBinding { - internal GeneratedConstructorBinding(IReadOnlyList parameters) + internal GeneratedConstructorBinding( + IMethodSymbol constructor, + IReadOnlyList parameters, + int score) { + Constructor = constructor; Parameters = parameters; + Score = score; } + internal IMethodSymbol Constructor { get; } + internal IReadOnlyList Parameters { get; } + + internal int Score { get; } + + internal bool Uses(GeneratedPropertyBinding leaf) + { + return Parameters.Any(parameter => parameter.Leaf == leaf); + } + + internal bool Uses(GeneratedMaterializationNode child) + { + return Parameters.Any(parameter => parameter.Child == child); + } } private sealed class GeneratedConstructorParameter { - internal GeneratedConstructorParameter(string localName, string typeName, int ordinal) + internal GeneratedConstructorParameter( + string localName, + string typeName, + GeneratedPropertyBinding leaf, + GeneratedMaterializationNode child) { LocalName = localName; TypeName = typeName; - Ordinal = ordinal; + Leaf = leaf; + Child = child; } internal string LocalName { get; } internal string TypeName { get; } - internal int Ordinal { get; } + internal GeneratedPropertyBinding Leaf { get; } + + internal GeneratedMaterializationNode Child { get; } + } + + private sealed class GeneratedParameterMatch + { + private GeneratedParameterMatch( + GeneratedPropertyBinding leaf, + GeneratedMaterializationNode child, + int score) + { + Leaf = leaf; + Child = child; + Score = score; + } + + internal GeneratedPropertyBinding Leaf { get; } + + internal GeneratedMaterializationNode Child { get; } + + internal int Score { get; } + + internal static GeneratedParameterMatch ForLeaf(GeneratedPropertyBinding leaf, int score) + { + return new GeneratedParameterMatch(leaf, null, score); + } + + internal static GeneratedParameterMatch ForChild(GeneratedMaterializationNode child, int score) + { + return new GeneratedParameterMatch(null, child, score); + } } } } diff --git a/src/Dapper.FluentMap.Generators/README.md b/src/Dapper.FluentMap.Generators/README.md index 3628cd2..afe1fcb 100644 --- a/src/Dapper.FluentMap.Generators/README.md +++ b/src/Dapper.FluentMap.Generators/README.md @@ -1,8 +1,8 @@ # Dapper.FluentMap.Generators -Build-time source generator for Dapper.FluentMap mapping registration and supported flat row materializers. +Build-time source generator for Dapper.FluentMap mapping registration and supported row materializers. -The generator discovers eligible `IEntityMap` implementations declared in the current compilation and emits an `AddGeneratedMappings()` extension method that registers them through the existing `AddMap()` / `AddProfile()` APIs. For flat explicit maps with literal columns and simple scalar properties, it also registers generated `IDataRecord -> entity` materializers for the matching ordered column shape. +The generator discovers eligible `IEntityMap` implementations declared in the current compilation and emits an `AddGeneratedMappings()` extension method that registers them through the existing `AddMap()` / `AddProfile()` APIs. For explicit maps with literal columns and supported deterministic construction, it also registers generated `IDataRecord -> entity` materializers for the matching ordered column shape, including flat properties, nested object paths and constructor-built Value Objects. ```bash dotnet add package Dapper.FluentMap.Generators diff --git a/test/Dapper.FluentMap.GeneratedRegistration.Tests/GeneratedRegistrationIntegrationTests.cs b/test/Dapper.FluentMap.GeneratedRegistration.Tests/GeneratedRegistrationIntegrationTests.cs index 4c0c906..01f2700 100644 --- a/test/Dapper.FluentMap.GeneratedRegistration.Tests/GeneratedRegistrationIntegrationTests.cs +++ b/test/Dapper.FluentMap.GeneratedRegistration.Tests/GeneratedRegistrationIntegrationTests.cs @@ -41,6 +41,18 @@ public void GeneratedRegistrationShouldWorkWithDapperAndExistingMappingFeatures( "SELECT '2026-07-26T10:30:00' AS created_at;"); var profiled = connection.QueryMappedSingle( "SELECT 11 AS legacy_id, 'Profiled' AS legacy_name;"); + var nested = connection.QueryMappedSingle( + "SELECT 13 AS customer_id, 'Sao Paulo' AS city;"); + var nullableNested = connection.QueryMappedSingle( + "SELECT 14 AS customer_id, NULL AS city;"); + var valueObject = connection.QueryMappedSingle( + "SELECT 15 AS customer_id, '12345678909' AS cpf;"); + var nullableValueObject = connection.QueryMappedSingle( + "SELECT 16 AS customer_id, NULL AS cpf;"); + var sameTerminal = connection.QueryMappedSingle( + "SELECT 5 AS rank_level, 9 AS seniority_level;"); + var profiledNested = connection.QueryMappedSingle( + "SELECT 'Profile City' AS legacy_city;"); Assert.Equal(7, customer.Id); Assert.Equal("Ada", customer.Name); @@ -56,6 +68,18 @@ public void GeneratedRegistrationShouldWorkWithDapperAndExistingMappingFeatures( Assert.Equal(new DateTime(2026, 7, 26, 10, 30, 0), named.CreatedAt); Assert.Equal(11, profiled.Id); Assert.Equal("Profiled", profiled.Name); + Assert.Equal(13, nested.Id); + Assert.NotNull(nested.Address); + Assert.Equal("Sao Paulo", nested.Address.City); + Assert.Equal(14, nullableNested.Id); + Assert.Null(nullableNested.Address); + Assert.Equal(15, valueObject.Id); + Assert.Equal("12345678909", valueObject.Cpf.Number); + Assert.Equal(16, nullableValueObject.Id); + Assert.Null(nullableValueObject.Cpf); + Assert.Equal(5, sameTerminal.Rank.Level); + Assert.Equal(9, sameTerminal.Seniority.Level); + Assert.Equal("Profile City", profiledNested.Address.City); Assert.Equal(0, FluentMapper.Registry.MaterializationPlanCacheEntryCount); var fallback = connection.QueryMappedSingle( @@ -89,7 +113,11 @@ private static void ResetMapper() typeof(GeneratedImmutableCustomer), typeof(GeneratedNullableCustomer), typeof(GeneratedNamingCustomer), - typeof(GeneratedProfileCustomer)); + typeof(GeneratedProfileCustomer), + typeof(GeneratedNestedCustomer), + typeof(GeneratedValueObjectCustomer), + typeof(GeneratedSameTerminalCustomer), + typeof(GeneratedProfileNestedCustomer)); } } @@ -210,4 +238,114 @@ public GeneratedLegacyProfileCustomerMap() Map(customer => customer.Name).ToColumn("legacy_name"); } } + + public sealed class GeneratedNestedCustomer + { + public int Id { get; set; } + + public GeneratedAddress Address { get; set; } + } + + public sealed class GeneratedAddress + { + public string City { get; set; } + } + + public sealed class GeneratedNestedCustomerMap : EntityMap + { + public GeneratedNestedCustomerMap() + { + Map(customer => customer.Id).ToColumn("customer_id"); + Map(customer => customer.Address.City).ToColumn("city"); + } + } + + public sealed class GeneratedValueObjectCustomer + { + public GeneratedValueObjectCustomer(int id, GeneratedCpf cpf) + { + Id = id; + Cpf = cpf; + } + + public int Id { get; } + + public GeneratedCpf Cpf { get; } + } + + public sealed class GeneratedCpf + { + public GeneratedCpf(string number) + { + Number = number; + } + + public string Number { get; } + } + + public sealed class GeneratedValueObjectCustomerMap : EntityMap + { + public GeneratedValueObjectCustomerMap() + { + Map(customer => customer.Id).ToColumn("customer_id"); + Map(customer => customer.Cpf.Number).ToColumn("cpf"); + } + } + + public sealed class GeneratedSameTerminalCustomer + { + public GeneratedSameTerminalCustomer(GeneratedRank rank, GeneratedSeniority seniority) + { + Rank = rank; + Seniority = seniority; + } + + public GeneratedRank Rank { get; } + + public GeneratedSeniority Seniority { get; } + } + + public sealed class GeneratedRank + { + public GeneratedRank(int level) + { + Level = level; + } + + public int Level { get; } + } + + public sealed class GeneratedSeniority + { + public GeneratedSeniority(int level) + { + Level = level; + } + + public int Level { get; } + } + + public sealed class GeneratedSameTerminalCustomerMap : EntityMap + { + public GeneratedSameTerminalCustomerMap() + { + Map(customer => customer.Rank.Level).ToColumn("rank_level"); + Map(customer => customer.Seniority.Level).ToColumn("seniority_level"); + } + } + + public sealed class GeneratedProfileNestedCustomer + { + public GeneratedAddress Address { get; set; } + } + + public sealed class GeneratedLegacyProfileNestedCustomerMap : + EntityMap, + IProfileMap + { + public GeneratedLegacyProfileNestedCustomerMap() + { + Map(customer => customer.Address.City).ToColumn("legacy_city"); + } + } } diff --git a/test/Dapper.FluentMap.Generators.Tests/MappingRegistrationGeneratorTests.cs b/test/Dapper.FluentMap.Generators.Tests/MappingRegistrationGeneratorTests.cs index a0807b0..ccbcf97 100644 --- a/test/Dapper.FluentMap.Generators.Tests/MappingRegistrationGeneratorTests.cs +++ b/test/Dapper.FluentMap.Generators.Tests/MappingRegistrationGeneratorTests.cs @@ -134,7 +134,7 @@ public CustomerMap() Assert.Empty(result.DfmDiagnostics); Assert.Contains("var id = Read(record, 0);", result.GeneratedSource, StringComparison.Ordinal); Assert.Contains("var fullName = Read(record, 1);", result.GeneratedSource, StringComparison.Ordinal); - Assert.Contains("return new global::Customer(id, fullName);", result.GeneratedSource, StringComparison.Ordinal); + Assert.Contains("entity = new global::Customer(id, fullName);", result.GeneratedSource, StringComparison.Ordinal); Assert.Contains("FluentMapConfigurationException", result.GeneratedSource, StringComparison.Ordinal); } @@ -358,7 +358,7 @@ public LegacyCustomerMap() } [Fact] - public void UnsupportedNestedMappingShouldReportFallbackDiagnostic() + public void NestedMutableMappingShouldGenerateComplexMaterializer() { var source = @" using Dapper.FluentMap.Mapping; @@ -381,12 +381,148 @@ public CustomerMap() } }"; + var result = RunGenerator(source); + + Assert.Empty(result.DfmDiagnostics); + Assert.Contains(".AddMap()", result.GeneratedSource, StringComparison.Ordinal); + Assert.Contains(".AddGeneratedMaterializer(", result.GeneratedSource, StringComparison.Ordinal); + Assert.Contains("GeneratedMaterializerColumn.Map(\"city\", \"Address.City\")", result.GeneratedSource, StringComparison.Ordinal); + Assert.Contains("if (!record.IsDBNull(0))", result.GeneratedSource, StringComparison.Ordinal); + Assert.Contains("node1.City = Read(record, 0);", result.GeneratedSource, StringComparison.Ordinal); + Assert.Contains("entity.Address = null;", result.GeneratedSource, StringComparison.Ordinal); + } + + [Fact] + public void ValueObjectMappingShouldGenerateConstructorComposition() + { + var source = @" +using Dapper.FluentMap.Mapping; + +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; } +} + +public sealed class CustomerMap : EntityMap +{ + public CustomerMap() + { + Map(customer => customer.Id).ToColumn(""customer_id""); + Map(customer => customer.Cpf.Number).ToColumn(""cpf""); + } +}"; + + var result = RunGenerator(source); + + Assert.Empty(result.DfmDiagnostics); + Assert.Contains("GeneratedMaterializerColumn.Map(\"cpf\", \"Cpf.Number\")", result.GeneratedSource, StringComparison.Ordinal); + Assert.Contains("global::Cpf cpf = null;", result.GeneratedSource, StringComparison.Ordinal); + Assert.Contains("cpf = new global::Cpf(arg1_0);", result.GeneratedSource, StringComparison.Ordinal); + Assert.Contains("entity = new global::Customer(id, cpf);", result.GeneratedSource, StringComparison.Ordinal); + } + + [Fact] + public void SameTerminalNestedPathsShouldUseFullMemberPathsInDescriptor() + { + var source = @" +using Dapper.FluentMap.Mapping; + +public sealed class Customer +{ + 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() + { + Map(customer => customer.Rank.Level).ToColumn(""rank_level""); + Map(customer => customer.Seniority.Level).ToColumn(""seniority_level""); + } +}"; + + var result = RunGenerator(source); + + Assert.Empty(result.DfmDiagnostics); + Assert.Contains("GeneratedMaterializerColumn.Map(\"rank_level\", \"Rank.Level\")", result.GeneratedSource, StringComparison.Ordinal); + Assert.Contains("GeneratedMaterializerColumn.Map(\"seniority_level\", \"Seniority.Level\")", result.GeneratedSource, StringComparison.Ordinal); + Assert.Contains("node1.Level = Read(record, 0);", result.GeneratedSource, StringComparison.Ordinal); + Assert.Contains("node2.Level = Read(record, 1);", result.GeneratedSource, StringComparison.Ordinal); + } + + [Fact] + public void IncompatibleNestedConstructorShouldReportFallbackDiagnostic() + { + var source = @" +using Dapper.FluentMap.Mapping; + +public sealed class Customer +{ + public Customer(Cpf cpf) + { + Cpf = cpf; + } + + public Cpf Cpf { get; } +} + +public sealed class Cpf +{ + public Cpf(string number, string kind) + { + Number = number; + Kind = kind; + } + + public string Number { get; } + + public string Kind { get; } +} + +public sealed class CustomerMap : EntityMap +{ + public CustomerMap() + { + Map(customer => customer.Cpf.Number).ToColumn(""cpf""); + } +}"; + var result = RunGenerator(source); var diagnostic = Assert.Single(result.DfmDiagnostics); Assert.Equal(MappingRegistrationGenerator.SkippedGeneratedMaterializerDiagnosticId, diagnostic.Id); Assert.Equal(DiagnosticSeverity.Info, diagnostic.Severity); - Assert.Contains("nested member paths", diagnostic.GetMessage(), StringComparison.Ordinal); + Assert.Contains("supported public constructor", diagnostic.GetMessage(), StringComparison.Ordinal); Assert.Contains(".AddMap()", result.GeneratedSource, StringComparison.Ordinal); Assert.DoesNotContain(".AddGeneratedMaterializer(", result.GeneratedSource, StringComparison.Ordinal); } From a3c1c7395c8c90b256da03bf16e6b019977d7fbb Mon Sep 17 00:00:00 2001 From: Rodrigo de Oliveira Date: Tue, 28 Jul 2026 10:40:13 -0300 Subject: [PATCH 06/49] feat(materialization): integrate generated query materializers --- .sdd/etapa-7/02-performance-baseline.md | 67 ++++++ .sdd/etapa-7/06-runtime-integration.md | 194 ++++++++++++++++++ .sdd/etapa-7/DECISIONS.md | 45 ++++ .sdd/etapa-7/STATUS.md | 42 +++- .../Dapper.FluentMap.Benchmarks/Program.cs | 27 +++ src/Dapper.FluentMap/MappingRegistry.cs | 14 ++ .../GeneratedRegistrationIntegrationTests.cs | 74 +++++++ .../DiagnosticsApiTests.cs | 32 +++ .../GeneratedMaterializerContractTests.cs | 160 +++++++++++++++ 9 files changed, 650 insertions(+), 5 deletions(-) create mode 100644 .sdd/etapa-7/06-runtime-integration.md diff --git a/.sdd/etapa-7/02-performance-baseline.md b/.sdd/etapa-7/02-performance-baseline.md index 30ef2fe..b9a9078 100644 --- a/.sdd/etapa-7/02-performance-baseline.md +++ b/.sdd/etapa-7/02-performance-baseline.md @@ -227,3 +227,70 @@ Job: `ShortRun`, `LaunchCount=1`, `WarmupCount=3`, `IterationCount=3`. - Nao foi executada rodada cold dedicada para generated complex. O benchmark cold existente registra maps manualmente por `AddMap(...)` e continua representando o fallback/runtime. - Os resultados continuam locais e devem ser usados como indicio de alocacao, nao como contrato de performance. + +## Apos Prompt 7.6 + +Prompt 7.6 manteve o dispatch generated antes da iteracao de linhas em `QueryMapped*`, adicionou diagnostics seguros em `Explain()` para presenca de descriptors generated e expandiu o benchmark steady state com cenarios de fallback runtime por shape equivalente em ordem diferente. + +### Comandos Executados + +Rodada steady state: + +```bash +dotnet run --project .\benchmarks\Dapper.FluentMap.Benchmarks\Dapper.FluentMap.Benchmarks.csproj --configuration Release --no-build -- --filter *MaterializationSteadyStateBenchmarks* +``` + +Rodada cold start: + +```bash +dotnet run --project .\benchmarks\Dapper.FluentMap.Benchmarks\Dapper.FluentMap.Benchmarks.csproj --configuration Release --no-build -- --filter *MaterializationColdStartBenchmarks* +``` + +### Resultados - Steady State + +Job: `ShortRun`, `LaunchCount=1`, `WarmupCount=3`, `IterationCount=3`. + +| Method | Mean | StdDev | Ratio | Gen0 | Gen1 | Allocated | Alloc Ratio | +| --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: | +| QueryMappedValueObjectRuntimeFallback | 1.256 ms | 0.1222 ms | 0.92 | 138.6719 | 27.3438 | 587.9 KB | 2.08 | +| QueryMappedNestedObjectRuntimeFallback | 1.291 ms | 0.0129 ms | 0.95 | 91.7969 | 29.2969 | 377.06 KB | 1.33 | +| DapperWithFluentMapRootMapping | 1.359 ms | 0.0276 ms | 1.00 | 68.3594 | - | 283.3 KB | 1.00 | +| DapperPure | 1.360 ms | 0.0383 ms | 1.00 | 68.3594 | - | 283.17 KB | 1.00 | +| QueryMappedValueObject | 1.435 ms | 0.0619 ms | 1.06 | 62.5000 | 19.5313 | 276.47 KB | 0.98 | +| QueryMappedSimpleRuntimeFallback | 1.572 ms | 0.0932 ms | 1.16 | 87.8906 | 19.5313 | 361.48 KB | 1.28 | +| QueryMappedNestedObject | 1.683 ms | 0.0671 ms | 1.24 | 70.3125 | 21.4844 | 292.44 KB | 1.03 | +| QueryMappedSimple | 1.838 ms | 0.1143 ms | 1.35 | 62.5000 | 5.8594 | 261.12 KB | 0.92 | +| QueryMappedImmutableConstructor | 1.843 ms | 0.0743 ms | 1.36 | 62.5000 | 11.7188 | 261.05 KB | 0.92 | + +### Leitura - Steady State + +- O benchmark agora explicita `QueryMapped*` generated e `QueryMapped*` runtime fallback lado a lado. +- O ganho mais confiavel permanece em alocacao: + - simple: generated `261.12 KB` vs runtime fallback `361.48 KB`; + - nested: generated `292.44 KB` vs runtime fallback `377.06 KB`; + - Value Object: generated `276.47 KB` vs runtime fallback `587.9 KB`. +- Tempo continua ruidoso no `ShortRun`; nesta rodada os fallback runtime de nested/value object apareceram mais rapidos que os shapes generated, mas isso nao deve ser interpretado como regressao funcional ou promessa de tempo. A diferenca de alocacao e mais estavel e alinhada as rodadas anteriores. +- `DapperWithFluentMapRootMapping` continuou alinhado com Dapper puro em tempo e alocacao. + +### Resultados - Cold Start + +Job: `RunStrategy=ColdStart`, `LaunchCount=8`, `WarmupCount=0`, `IterationCount=1`. + +| Method | Mean | StdDev | Ratio | Allocated | Alloc Ratio | +| --- | ---: | ---: | ---: | ---: | ---: | +| DapperPureColdStart | 165.7 ms | 33.08 ms | 1.03 | 285.95 KB | 1.00 | +| FluentMapRootMappingColdStart | 219.5 ms | 55.54 ms | 1.37 | 353.13 KB | 1.23 | +| QueryMappedNestedColdStart | 228.4 ms | 32.89 ms | 1.42 | 442.84 KB | 1.55 | +| QueryMappedValueObjectColdStart | 271.0 ms | 47.20 ms | 1.69 | 645.09 KB | 2.26 | + +### Leitura - Cold Start + +- Cold start continua com variancia alta e outliers. +- A rodada cold existente continua representando configuracao manual/runtime fallback, nao generated cold start dedicado. +- Nao foi criada API publica de reset/isolamento apenas para medir cold generated. + +### Limitacoes + +- Benchmarks locais usam SQLite em memoria e medem tambem provider/SQL/reader. +- `ShortRun` serve para acompanhamento rapido, nao para afirmar SLA de performance. +- O comparativo runtime fallback por ordem diferente valida dispatch por shape, mas a ordem de colunas tambem pode afetar ruido do provider. diff --git a/.sdd/etapa-7/06-runtime-integration.md b/.sdd/etapa-7/06-runtime-integration.md new file mode 100644 index 0000000..d5f663a --- /dev/null +++ b/.sdd/etapa-7/06-runtime-integration.md @@ -0,0 +1,194 @@ +# Runtime Integration de Generated Materializers + +Status: SPECIFICATION + IMPLEMENTATION +Prompt: 7.6 +Data: 2026-07-28 + +## Objetivo + +Integrar `QueryMapped*` ao caminho de materializacao gerada quando existir um descriptor compativel, preservando `NestedMaterializationPlan` como fallback autoritativo. + +O consumidor continua usando a mesma API: + +```csharp +connection.QueryMapped(sql); +connection.QueryMappedSingle(sql); +``` + +Nenhum detalhe de dispatch, descriptor ou fallback e exigido do usuario. + +## Lookup + +O lookup acontece uma vez por execucao de query, depois que o `IDataReader` esta aberto e antes da iteracao das linhas. + +Chave: + +```text +EntityType + ProfileType opcional + ordered ColumnShape +``` + +O shape e a sequencia exata de nomes retornados por `IDataRecord.GetName(ordinal)`. A ordem importa porque o materializer gerado usa ordinais fixos. + +## Dispatch + +Fluxo: + +```text +QueryMapped + -> Dapper ExecuteReader + -> ler column names + -> TryGetGeneratedMaterializer(entity, profile, ordered shape) + -> found e valido: usar delegate gerado por linha + -> missing/invalido: usar GetMaterializationPlan runtime + -> List bufferizada +``` + +O dispatch fica antes do loop `reader.Read()`. Assim: + +- nao ha lookup por linha; +- o hot path gerado chama apenas o delegate direto; +- reflection e validacao de descriptor ficam fora da iteracao; +- fallback runtime preserva os caches existentes. + +## Caching + +Generated materializers sao armazenados no registry interno por `MaterializationPlanCacheKey`. + +Propriedades da chave: + +- tipo da entidade; +- tipo de profile ou `null`; +- nomes de colunas em ordem ordinal; +- comparacao ordinal de strings; +- hash estruturado, sem concatenacao textual. + +O registry generated cresce apenas por descriptors registrados, nao por cada query executada. Queries com shapes desconhecidos nao criam entradas generated. O cache runtime de `NestedMaterializationPlan` continua separado e cresce por shapes efetivamente usados no fallback, como antes desta etapa. + +## Profiles + +Profiles possuem descriptors separados do map default: + +```text +Customer + null + shape +Customer + LegacyProfile + shape +``` + +`QueryMapped()` procura somente o descriptor default. `QueryMapped()` exige que o profile esteja registrado e procura somente o descriptor daquele profile. + +Um generated descriptor nao registra profile implicitamente e nao altera o type map global do Dapper. + +## Validacao de Descriptor + +Um descriptor encontrado pelo shape ainda precisa bater com o mapping efetivo atual. + +Para cada coluna: + +- coluna materializada deve resolver para o mesmo `MemberPath`; +- coluna ignorada deve continuar ignorada; +- coluna materializada nao pode resolver para mapping ignorado; +- quando nao houver FluentMap/convention/profile, o fallback default do Dapper precisa apontar para o mesmo membro root; +- profile ausente continua falhando com `FluentMapConfigurationException`. + +Se qualquer verificacao falhar, o descriptor e tratado como invalido e o runtime fallback materializa a query. + +## Invalid Generated Materializer + +Descriptor invalido nao e erro de consulta por si so. Ele e uma otimizacao rejeitada. + +Exemplos: + +- descriptor gerado a partir de metadata antiga; +- column shape com mesma coluna em ordem diferente; +- member path divergente; +- coluna esperada como ignorada mas configuracao efetiva materializa; +- coluna esperada como materializada mas configuracao efetiva ignora. + +O fallback evita que um delegate gerado com ordinais incorretos produza objeto incorreto. + +## Diagnostics + +`Explain()` nao conhece o SQL nem o shape real do reader. Por isso ele nao deve prometer que uma query especifica usara generated materializer. + +Nesta etapa, `MappingExplanation.Diagnostics` passa a indicar somente a presenca de descriptors generated registrados para a entidade/profile e deixa claro que a selecao real depende de: + +- ordem de colunas do reader; +- compatibilidade com o mapping efetivo; +- fallback runtime quando nao houver match seguro. + +Nao foi adicionado enum publico `Generated` a `MappingMaterialization`, porque esse enum descreve como cada membro e materializado semanticamente (`Dapper`, `Nested`, `ValueObject`), nao qual delegate foi escolhido para uma query especifica. + +Fallback reason por query permanece fora da API publica nesta etapa para evitar contrato fragil baseado em SQL/reader shape. Uma API futura pode receber explicitamente um shape de colunas e retornar uma explicacao de dispatch. + +## Thread Safety + +O registry usa `ConcurrentDictionary` para: + +- maps default; +- profile maps; +- conventions; +- materialization plans runtime; +- generated materializers. + +Lookup generated e lock-free sobre snapshot de descriptor. O delegate gerado precisa ser thread-safe por contrato pratico: ele nao deve guardar estado mutavel por linha. O generator atual emite metodos estaticos sem estado. + +Registros duplicados para a mesma entidade/profile/shape sao rejeitados de forma deterministica. + +## Startup Behavior + +O fluxo recomendado continua: + +```csharp +FluentMapper.Initialize(config => +{ + config.AddGeneratedMappings(); +}); +``` + +`AddGeneratedMappings()` registra maps/profiles pelos caminhos existentes e, para mapas geraveis, registra descriptors de materializer. + +Sem pacote generator, sem descriptor gerado ou com map nao geravel, `QueryMapped*` usa o fallback runtime existente. + +## Configuration Lifecycle + +O FluentMap continua sendo configuracao global de processo e deve ser inicializado no startup. + +Registros feitos por APIs de configuracao invalidam caches runtime de mapping/plano do tipo afetado. Descriptors gerados nao sao removidos nessas invalidacoes; eles continuam registrados, mas so sao usados se passarem pela validacao contra o mapping efetivo no lookup. + +`FluentMapper.Reset(...)` limpa maps, profiles, conventions, caches runtime e generated descriptors. Os dicionarios publicos mutaveis legados permanecem por compatibilidade, mas alteracoes diretas neles fora do lifecycle recomendado podem contornar invalidacoes, como ja ocorria com caches existentes. + +## Backward Compatibility + +Compatibilidade preservada: + +- `QueryMapped*` continua aceitando os mesmos parametros e retornando resultados bufferizados; +- `Dapper.Query()` e type maps globais nao mudam; +- profiles continuam query-scoped; +- fallback runtime permanece obrigatorio; +- annotations `RequiresUnreferencedCode` e `RequiresDynamicCode` permanecem porque qualquer query ainda pode cair no fallback; +- consumidores sem generator nao precisam mudar codigo. + +## Testes + +Cobertura esperada nesta etapa: + +- generated selected antes da iteracao; +- fallback selected quando descriptor esta ausente; +- fallback selected quando metadata generated e invalida; +- profiles generated; +- nested generated via source generator; +- immutable constructor generated via source generator; +- Value Object generated via source generator; +- concorrencia de lookup e de queries; +- queries repetidas sem crescimento de cache runtime no generated path; +- equivalencia funcional entre resultado runtime e generated para shapes equivalentes. + +## Benchmarks + +A suite principal deve comparar explicitamente: + +- Dapper puro; +- Dapper + FluentMap root mapping; +- `QueryMapped*` com shape canonico gerado; +- `QueryMapped*` runtime fallback por shape equivalente em ordem diferente. + +Resultados locais devem ser registrados em `.sdd/etapa-7/02-performance-baseline.md` como evidencia, nao como promessa publica. diff --git a/.sdd/etapa-7/DECISIONS.md b/.sdd/etapa-7/DECISIONS.md index 8e80088..c3db8b8 100644 --- a/.sdd/etapa-7/DECISIONS.md +++ b/.sdd/etapa-7/DECISIONS.md @@ -320,3 +320,48 @@ Cada generated node carrega os ordinais materializados da sua subarvore. O codig - Subarvore parcialmente preenchida cria objeto. - Value Objects usados como argumentos de construtor recebem `null` quando todos os componentes sao `NULL`. - A semantica fica alinhada ao runtime sem alocar arrays por linha no hot path gerado. + +## ADR-7.6-001 - Dispatch Generated Acontece Por Query, Antes Da Iteracao + +### Contexto + +`QueryMapped*` ja le o shape de colunas uma vez antes de materializar as linhas. O materializer gerado usa ordinais fixos e nao deve pagar lookup custoso por linha. + +### Decisao + +O runtime deve procurar um generated materializer por entidade, profile e shape ordenado imediatamente depois de abrir o reader e coletar os nomes das colunas. Quando o descriptor for encontrado e validado contra o mapping efetivo, o loop de linhas chama diretamente o delegate gerado. Caso contrario, o runtime cria/usa o `NestedMaterializationPlan` cacheado. + +### Alternativas + +- Procurar descriptor gerado dentro do loop de linhas. +- Criar uma API separada para generated materialization. +- Sempre criar o plano runtime antes de tentar generated. + +### Consequencias + +- Evita lookup e reflection no hot path por linha. +- Mantem fallback runtime sem mudar a API do consumidor. +- O custo de validacao generated fica por query/shape. +- Shapes inesperados continuam exercitando o cache runtime existente. + +## ADR-7.6-002 - Diagnostics Publicos Permanecem Conservadores + +### Contexto + +`Explain()` nao recebe SQL nem `IDataReader`, portanto nao conhece a ordem real das colunas. Informar que uma query especifica usara generated materializer nesse ponto criaria um contrato fragil. + +### Decisao + +`MappingExplanation.Diagnostics` pode indicar que descriptors generated estao registrados para a entidade/profile e explicar que `QueryMapped*` so os seleciona quando o shape do reader e o mapping efetivo ainda batem. Fallback reason por query nao sera exposto em API publica nesta etapa. + +### Alternativas + +- Adicionar `Generated` em `MappingMaterialization`. +- Expor fallback reason no `Explain()` sem shape de colunas. +- Nao adicionar nenhum diagnostico publico. + +### Consequencias + +- Usuarios conseguem ver que ha cobertura generated registrada. +- A API nao promete dispatch generated para queries que `Explain()` nao consegue avaliar. +- Uma API futura pode receber explicitamente um column shape e retornar diagnostico de dispatch mais preciso. diff --git a/.sdd/etapa-7/STATUS.md b/.sdd/etapa-7/STATUS.md index 6f2e0f5..b1e7818 100644 --- a/.sdd/etapa-7/STATUS.md +++ b/.sdd/etapa-7/STATUS.md @@ -115,6 +115,39 @@ Definir a arquitetura e a especificacao inicial para materializacao gerada no Fl - QueryMappedImmutableConstructor: 1.734 ms, 261.05 KB; - QueryMappedNestedObject: 1.670 ms, 292.44 KB; - QueryMappedValueObject: 1.392 ms, 276.47 KB. +- Criada a especificacao `.sdd/etapa-7/06-runtime-integration.md`. +- Confirmado que `QueryMapped*` faz dispatch generated antes da iteracao de linhas e preserva fallback runtime por `NestedMaterializationPlan`. +- Adicionado diagnostic seguro em `Explain()` / `Explain()` quando descriptors generated estao registrados para a entidade/profile. +- Mantido fallback reason por query fora da API publica porque `Explain()` nao conhece o shape real do reader. +- Adicionados testes de integracao para: + - generated materializer default selecionado; + - generated materializer por profile; + - fallback runtime quando generated esta ausente; + - fallback runtime quando metadata generated nao bate com o mapping efetivo; + - queries repetidas com cache runtime estavel; + - concorrencia de queries no caminho generated; + - equivalencia funcional entre generated e runtime fallback para immutable, nested, Value Object, mesmo terminal e profile nested. +- Atualizado benchmark steady state para comparar `QueryMapped*` generated e runtime fallback por shape equivalente em ordem diferente. +- Executado `dotnet test .\test\Dapper.FluentMap.Tests\Dapper.FluentMap.Tests.csproj --configuration Release --filter "FullyQualifiedName~GeneratedMaterializerContractTests|FullyQualifiedName~DiagnosticsApiTests"`: sucesso, 25 testes aprovados. +- Executado `dotnet test .\test\Dapper.FluentMap.GeneratedRegistration.Tests\Dapper.FluentMap.GeneratedRegistration.Tests.csproj --configuration Release`: sucesso, 2 testes aprovados. +- Executado `dotnet restore .\Dapper.FluentMap.sln`: sucesso. +- Executado `dotnet build .\Dapper.FluentMap.sln --configuration Release --no-restore`: sucesso, 0 warnings, 0 errors. +- Executado `dotnet test .\Dapper.FluentMap.sln --configuration Release --no-build`: sucesso, 253 testes aprovados. +- Executada rodada benchmark steady state `MaterializationSteadyStateBenchmarks`: sucesso. +- Executada rodada benchmark cold start `MaterializationColdStartBenchmarks`: sucesso. +- Executado `dotnet pack .\src\Dapper.FluentMap\Dapper.FluentMap.csproj --configuration Release --no-build --output .\artifacts\packages`: sucesso, gerou `Dapper.FluentMap.2.0.0.nupkg`. +- Warnings conhecidos no pack: `NU5125` por `PackageLicenseUrl` legado e recomendacao NuGet para README de pacote. +- Atualizada a secao `Apos Prompt 7.6` em `.sdd/etapa-7/02-performance-baseline.md`. +- Benchmark steady state resumido apos 7.6: + - DapperPure: 1.360 ms, 283.17 KB; + - DapperWithFluentMapRootMapping: 1.359 ms, 283.3 KB; + - QueryMappedSimple generated: 1.838 ms, 261.12 KB; + - QueryMappedSimple runtime fallback: 1.572 ms, 361.48 KB; + - QueryMappedImmutableConstructor generated: 1.843 ms, 261.05 KB; + - QueryMappedNestedObject generated: 1.683 ms, 292.44 KB; + - QueryMappedNestedObject runtime fallback: 1.291 ms, 377.06 KB; + - QueryMappedValueObject generated: 1.435 ms, 276.47 KB; + - QueryMappedValueObject runtime fallback: 1.256 ms, 587.9 KB. ## Em andamento @@ -122,10 +155,8 @@ Nenhum no escopo deste prompt apos o commit local. ## Proximos passos -1. Adicionar diagnostics runtime de generated/fallback. -2. Repetir todos os benchmarks apos 7.6 para validar lookup generated/fallback integrado. -3. Avaliar uma forma segura de medir cold start generated sem expor reset publico desnecessario. -4. Validar trimming, Native AOT e performance antes de documentar ganhos. +1. Avaliar uma forma segura de medir cold start generated sem expor reset publico desnecessario. +2. Validar trimming, Native AOT e performance antes de documentar ganhos. ## Decisoes relevantes @@ -173,7 +204,8 @@ Nenhum no escopo deste prompt apos o commit local. - `.sdd/etapa-7/03-generated-materializer-contracts.md` - `.sdd/etapa-7/04-flat-generated-materializers.md` - `.sdd/etapa-7/05-complex-generated-materialization.md` +- `.sdd/etapa-7/06-runtime-integration.md` ## Ultimo prompt executado -7.5 +7.6 diff --git a/benchmarks/Dapper.FluentMap.Benchmarks/Program.cs b/benchmarks/Dapper.FluentMap.Benchmarks/Program.cs index ffe3ec9..ce8648d 100644 --- a/benchmarks/Dapper.FluentMap.Benchmarks/Program.cs +++ b/benchmarks/Dapper.FluentMap.Benchmarks/Program.cs @@ -46,9 +46,12 @@ public void GlobalSetup() DapperPure(); DapperWithFluentMapRootMapping(); QueryMappedSimple(); + QueryMappedSimpleRuntimeFallback(); QueryMappedImmutableConstructor(); QueryMappedNestedObject(); + QueryMappedNestedObjectRuntimeFallback(); QueryMappedValueObject(); + QueryMappedValueObjectRuntimeFallback(); } [GlobalCleanup] @@ -84,6 +87,14 @@ public int QueryMappedSimple() .Count(); } + [Benchmark] + public int QueryMappedSimpleRuntimeFallback() + { + return _connection.QueryMapped( + "SELECT Name AS full_name, Id AS customer_id, Age AS customer_age, Balance AS account_balance, CreatedAt AS created_at FROM BenchmarkRows;") + .Count(); + } + [Benchmark] public int QueryMappedImmutableConstructor() { @@ -100,6 +111,14 @@ public int QueryMappedNestedObject() .Count(); } + [Benchmark] + public int QueryMappedNestedObjectRuntimeFallback() + { + return _connection.QueryMapped( + "SELECT City AS city, Id AS customer_id, Name AS full_name, PostalCode AS postal_code, Country AS country FROM BenchmarkRows;") + .Count(); + } + [Benchmark] public int QueryMappedValueObject() { @@ -108,6 +127,14 @@ public int QueryMappedValueObject() .Count(); } + [Benchmark] + public int QueryMappedValueObjectRuntimeFallback() + { + return _connection.QueryMapped( + "SELECT Cpf AS cpf, Id AS customer_id, Balance AS amount, Currency AS currency FROM BenchmarkRows;") + .Count(); + } + private static SqliteConnection OpenPopulatedConnection() { var connection = new SqliteConnection("Data Source=:memory:"); diff --git a/src/Dapper.FluentMap/MappingRegistry.cs b/src/Dapper.FluentMap/MappingRegistry.cs index 6ac3f11..6937925 100644 --- a/src/Dapper.FluentMap/MappingRegistry.cs +++ b/src/Dapper.FluentMap/MappingRegistry.cs @@ -426,6 +426,15 @@ internal MappingExplanation Explain( diagnostics.Add("No FluentMap entity map or convention is registered for this entity. Dapper default mapping is used."); } + var generatedMaterializerCount = CountGeneratedMaterializers(type, profileType); + if (generatedMaterializerCount > 0) + { + diagnostics.Add( + generatedMaterializerCount == 1 + ? "One generated QueryMapped materializer descriptor is registered for this entity/profile. QueryMapped selects it only when the reader column order and effective mapping still match; otherwise it uses the runtime materializer fallback." + : generatedMaterializerCount + " generated QueryMapped materializer descriptors are registered for this entity/profile. QueryMapped selects one only when the reader column order and effective mapping still match; otherwise it uses the runtime materializer fallback."); + } + return new MappingExplanation( type, profileType, @@ -435,6 +444,11 @@ internal MappingExplanation Explain( diagnostics); } + private int CountGeneratedMaterializers(Type type, Type profileType) + { + return _generatedMaterializers.Keys.Count(key => key.Type == type && key.ProfileType == profileType); + } + internal void Reset(params Type[] dapperTypes) { EntityMaps.Clear(); diff --git a/test/Dapper.FluentMap.GeneratedRegistration.Tests/GeneratedRegistrationIntegrationTests.cs b/test/Dapper.FluentMap.GeneratedRegistration.Tests/GeneratedRegistrationIntegrationTests.cs index 01f2700..7631f17 100644 --- a/test/Dapper.FluentMap.GeneratedRegistration.Tests/GeneratedRegistrationIntegrationTests.cs +++ b/test/Dapper.FluentMap.GeneratedRegistration.Tests/GeneratedRegistrationIntegrationTests.cs @@ -96,6 +96,80 @@ public void GeneratedRegistrationShouldWorkWithDapperAndExistingMappingFeatures( } } + [Fact] + [Trait("Category", "Integration")] + public void GeneratedQueryMappedShouldMatchRuntimeFallbackForEquivalentComplexShapes() + { + ResetMapper(); + + try + { + GeneratedImmutableCustomer generatedImmutable; + GeneratedNestedCustomer generatedNested; + GeneratedValueObjectCustomer generatedValueObject; + GeneratedSameTerminalCustomer generatedSameTerminal; + GeneratedProfileNestedCustomer generatedProfileNested; + + FluentMapper.Initialize(configuration => configuration.AddGeneratedMappings()); + + using (var connection = OpenConnection()) + { + generatedImmutable = connection.QueryMappedSingle( + "SELECT 21 AS immutable_id, 'Generated Constructor' AS name;"); + generatedNested = connection.QueryMappedSingle( + "SELECT 22 AS customer_id, 'Sao Paulo' AS city;"); + generatedValueObject = connection.QueryMappedSingle( + "SELECT 23 AS customer_id, '12345678909' AS cpf;"); + generatedSameTerminal = connection.QueryMappedSingle( + "SELECT 3 AS rank_level, 8 AS seniority_level;"); + generatedProfileNested = connection.QueryMappedSingle( + "SELECT 'Profile City' AS legacy_city;"); + + Assert.Equal(0, FluentMapper.Registry.MaterializationPlanCacheEntryCount); + } + + ResetMapper(); + + FluentMapper.Initialize(configuration => + { + configuration.AddMap(); + configuration.AddMap(); + configuration.AddMap(); + configuration.AddMap(); + configuration.AddProfile(); + }); + + using (var connection = OpenConnection()) + { + var runtimeImmutable = connection.QueryMappedSingle( + "SELECT 21 AS immutable_id, 'Generated Constructor' AS name;"); + var runtimeNested = connection.QueryMappedSingle( + "SELECT 22 AS customer_id, 'Sao Paulo' AS city;"); + var runtimeValueObject = connection.QueryMappedSingle( + "SELECT 23 AS customer_id, '12345678909' AS cpf;"); + var runtimeSameTerminal = connection.QueryMappedSingle( + "SELECT 3 AS rank_level, 8 AS seniority_level;"); + var runtimeProfileNested = connection.QueryMappedSingle( + "SELECT 'Profile City' AS legacy_city;"); + + Assert.Equal(generatedImmutable.Id, runtimeImmutable.Id); + Assert.Equal(generatedImmutable.Name, runtimeImmutable.Name); + Assert.Equal(generatedNested.Id, runtimeNested.Id); + Assert.Equal(generatedNested.Address.City, runtimeNested.Address.City); + Assert.Equal(generatedValueObject.Id, runtimeValueObject.Id); + Assert.Equal(generatedValueObject.Cpf.Number, runtimeValueObject.Cpf.Number); + Assert.Equal(generatedSameTerminal.Rank.Level, runtimeSameTerminal.Rank.Level); + Assert.Equal(generatedSameTerminal.Seniority.Level, runtimeSameTerminal.Seniority.Level); + Assert.Equal(generatedProfileNested.Address.City, runtimeProfileNested.Address.City); + Assert.Equal(5, FluentMapper.Registry.MaterializationPlanCacheEntryCount); + } + } + finally + { + ResetMapper(); + } + } + private static SqliteConnection OpenConnection() { var connection = new SqliteConnection("Data Source=:memory:"); diff --git a/test/Dapper.FluentMap.Tests/DiagnosticsApiTests.cs b/test/Dapper.FluentMap.Tests/DiagnosticsApiTests.cs index 05c31f2..bf9f4c4 100644 --- a/test/Dapper.FluentMap.Tests/DiagnosticsApiTests.cs +++ b/test/Dapper.FluentMap.Tests/DiagnosticsApiTests.cs @@ -4,6 +4,7 @@ using Dapper.FluentMap.Conventions; using Dapper.FluentMap.Diagnostics; using Dapper.FluentMap.Mapping; +using Dapper.FluentMap.Materialization; using Dapper.FluentMap.Naming; using Xunit; @@ -266,6 +267,37 @@ public void ExplainRepeatedCallsShouldBeConsistentAndAvoidCacheSideEffects() } } + [Fact] + public void ExplainShouldMentionGeneratedQueryMaterializersWhenDescriptorsAreRegistered() + { + PreTest(typeof(ExplicitDiagnosticEntity)); + + try + { + FluentMapper.Initialize(c => + { + c.AddMap(new ExplicitDiagnosticMap()); + c.AddGeneratedMaterializer( + new[] + { + GeneratedMaterializerColumn.Map("explicit_id", nameof(ExplicitDiagnosticEntity.Id)) + }, + record => new ExplicitDiagnosticEntity { Id = Convert.ToInt32(record.GetValue(0)) }); + }); + + var explanation = FluentMapper.Explain(); + + Assert.Contains( + explanation.Diagnostics, + diagnostic => diagnostic.Contains("generated QueryMapped materializer descriptor") && + diagnostic.Contains("runtime materializer fallback")); + } + finally + { + PreTest(typeof(ExplicitDiagnosticEntity)); + } + } + private static MemberMappingExplanation SingleMember(MappingExplanation explanation, string memberPath) { return explanation.Members.Single(m => m.MemberPath == memberPath); diff --git a/test/Dapper.FluentMap.Tests/GeneratedMaterializerContractTests.cs b/test/Dapper.FluentMap.Tests/GeneratedMaterializerContractTests.cs index e54853b..d3cd096 100644 --- a/test/Dapper.FluentMap.Tests/GeneratedMaterializerContractTests.cs +++ b/test/Dapper.FluentMap.Tests/GeneratedMaterializerContractTests.cs @@ -1,6 +1,7 @@ using System; using System.Data; using System.Linq; +using System.Threading; using System.Threading.Tasks; using Dapper.FluentMap.Mapping; using Dapper.FluentMap.Materialization; @@ -252,6 +253,165 @@ public void QueryMappedShouldFallBackToRuntimeWhenGeneratedMaterializerIsMissing } } + [Fact] + [Trait("Category", "Integration")] + public void QueryMappedShouldUseGeneratedProfileMaterializerWhenRegistered() + { + PreTest(typeof(GeneratedContractCustomer)); + + try + { + FluentMapper.Initialize(configuration => + { + configuration.AddProfile(); + configuration.AddGeneratedMaterializer( + LegacyColumns(), + ReadLegacyGeneratedCustomer); + }); + + using (var connection = OpenConnection()) + { + var customer = connection.QueryMappedSingle( + "SELECT 51 AS legacy_id, 'Ada' AS legal_name;"); + + Assert.Equal(51, customer.Id); + Assert.Equal("legacy:Ada", customer.Name); + Assert.Equal(0, FluentMapper.Registry.MaterializationPlanCacheEntryCount); + } + } + finally + { + PreTest(typeof(GeneratedContractCustomer)); + } + } + + [Fact] + [Trait("Category", "Integration")] + public void QueryMappedShouldFallBackToRuntimeWhenGeneratedContractDoesNotMatchEffectiveMapping() + { + PreTest(typeof(GeneratedContractCustomer)); + + try + { + FluentMapper.Initialize(configuration => + { + configuration.AddMap(new GeneratedContractCustomerMap()); + configuration.AddGeneratedMaterializer( + new[] + { + GeneratedMaterializerColumn.Map("customer_id", nameof(GeneratedContractCustomer.Name)), + GeneratedMaterializerColumn.Map("full_name", nameof(GeneratedContractCustomer.Name)) + }, + ReadDefaultGeneratedCustomer); + }); + + using (var connection = OpenConnection()) + { + var customer = connection.QueryMappedSingle( + "SELECT 61 AS customer_id, 'Runtime' AS full_name;"); + + Assert.Equal(61, customer.Id); + Assert.Equal("Runtime", customer.Name); + Assert.Equal(1, FluentMapper.Registry.MaterializationPlanCacheEntryCount); + } + } + finally + { + PreTest(typeof(GeneratedContractCustomer)); + } + } + + [Fact] + [Trait("Category", "Integration")] + public void QueryMappedGeneratedAndRuntimeFallbackShouldReturnEquivalentResults() + { + PreTest(typeof(GeneratedContractCustomer)); + + try + { + FluentMapper.Initialize(configuration => + { + configuration.AddMap(new GeneratedContractCustomerMap()); + configuration.AddGeneratedMaterializer( + DefaultColumns(), + record => new GeneratedContractCustomer + { + Id = Convert.ToInt32(record.GetValue(0)), + Name = Convert.ToString(record.GetValue(1)) + }); + }); + + using (var connection = OpenConnection()) + { + var generated = connection.QueryMappedSingle( + "SELECT 71 AS customer_id, 'Equivalent' AS full_name;"); + var runtimeFallback = connection.QueryMappedSingle( + "SELECT 'Equivalent' AS full_name, 71 AS customer_id;"); + var repeatedRuntimeFallback = connection.QueryMappedSingle( + "SELECT 'Equivalent' AS full_name, 71 AS customer_id;"); + + Assert.Equal(generated.Id, runtimeFallback.Id); + Assert.Equal(generated.Name, runtimeFallback.Name); + Assert.Equal(runtimeFallback.Id, repeatedRuntimeFallback.Id); + Assert.Equal(runtimeFallback.Name, repeatedRuntimeFallback.Name); + Assert.Equal(1, FluentMapper.Registry.MaterializationPlanCacheEntryCount); + } + } + finally + { + PreTest(typeof(GeneratedContractCustomer)); + } + } + + [Fact] + [Trait("Category", "Integration")] + public void QueryMappedGeneratedMaterializerShouldRemainStableUnderConcurrentQueries() + { + PreTest(typeof(GeneratedContractCustomer)); + + try + { + var materializedRows = 0; + FluentMapper.Initialize(configuration => + { + configuration.AddMap(new GeneratedContractCustomerMap()); + configuration.AddGeneratedMaterializer( + DefaultColumns(), + record => + { + Interlocked.Increment(ref materializedRows); + return new GeneratedContractCustomer + { + Id = Convert.ToInt32(record.GetValue(0)), + Name = Convert.ToString(record.GetValue(1)) + }; + }); + }); + + var results = Enumerable.Range(0, 50) + .AsParallel() + .Select(index => + { + using (var connection = OpenConnection()) + { + var customer = connection.QueryMappedSingle( + $"SELECT {index} AS customer_id, 'generated-{index}' AS full_name;"); + + return customer.Id == index && customer.Name == $"generated-{index}"; + } + }) + .ToList(); + + Assert.All(results, Assert.True); + Assert.Equal(50, materializedRows); + Assert.Equal(0, FluentMapper.Registry.MaterializationPlanCacheEntryCount); + } + finally + { + PreTest(typeof(GeneratedContractCustomer)); + } + } + [Fact] public void GeneratedLookupShouldRemainStableUnderConcurrentReads() { From d7b872af57252f17164cb4aaa040307376dfbcd1 Mon Sep 17 00:00:00 2001 From: Rodrigo de Oliveira Date: Tue, 28 Jul 2026 11:02:07 -0300 Subject: [PATCH 07/49] perf(materialization): harden generated materialization path --- .sdd/etapa-7/02-performance-baseline.md | 60 +++++++ .sdd/etapa-7/FINAL-REPORT.md | 152 ++++++++++++++++++ .sdd/etapa-7/STATUS.md | 21 +++ README.md | 4 +- .../Dapper.FluentMap.AotSmoke.csproj | 2 + test/Dapper.FluentMap.AotSmoke/Program.cs | 26 +++ .../GeneratedRegistrationIntegrationTests.cs | 23 ++- .../MappingRegistrationGeneratorTests.cs | 31 ++++ 8 files changed, 316 insertions(+), 3 deletions(-) create mode 100644 .sdd/etapa-7/FINAL-REPORT.md diff --git a/.sdd/etapa-7/02-performance-baseline.md b/.sdd/etapa-7/02-performance-baseline.md index b9a9078..c334dd6 100644 --- a/.sdd/etapa-7/02-performance-baseline.md +++ b/.sdd/etapa-7/02-performance-baseline.md @@ -294,3 +294,63 @@ Job: `RunStrategy=ColdStart`, `LaunchCount=8`, `WarmupCount=0`, `IterationCount= - Benchmarks locais usam SQLite em memoria e medem tambem provider/SQL/reader. - `ShortRun` serve para acompanhamento rapido, nao para afirmar SLA de performance. - O comparativo runtime fallback por ordem diferente valida dispatch por shape, mas a ordem de colunas tambem pode afetar ruido do provider. + +## Resultados finais da Etapa 7 + +Prompt 7.7 executou a rodada final representativa depois do hardening de smoke AOT/trimming e da regressao de ignored properties no caminho gerado. + +### Comandos Executados + +Rodada steady state: + +```bash +dotnet run --project .\benchmarks\Dapper.FluentMap.Benchmarks\Dapper.FluentMap.Benchmarks.csproj --configuration Release --no-build -- --filter *MaterializationSteadyStateBenchmarks* +``` + +Rodada cold start: + +```bash +dotnet run --project .\benchmarks\Dapper.FluentMap.Benchmarks\Dapper.FluentMap.Benchmarks.csproj --configuration Release --no-build -- --filter *MaterializationColdStartBenchmarks* +``` + +### Resultados - Steady State + +Job: `ShortRun`, `LaunchCount=1`, `WarmupCount=3`, `IterationCount=3`. + +| Method | Mean | StdDev | Ratio | Gen0 | Gen1 | Allocated | Alloc Ratio | +| --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: | +| QueryMappedValueObjectRuntimeFallback | 1.549 ms | 0.1973 ms | 0.74 | 138.6719 | 27.3438 | 587.9 KB | 2.08 | +| QueryMappedValueObject | 1.769 ms | 0.2233 ms | 0.85 | 62.5000 | 19.5313 | 276.47 KB | 0.98 | +| QueryMappedNestedObjectRuntimeFallback | 1.842 ms | 0.4287 ms | 0.89 | 89.8438 | 27.3438 | 377.06 KB | 1.33 | +| QueryMappedNestedObject | 1.994 ms | 0.1169 ms | 0.96 | 70.3125 | 23.4375 | 292.44 KB | 1.03 | +| DapperWithFluentMapRootMapping | 2.052 ms | 0.4532 ms | 0.99 | 66.4063 | - | 283.3 KB | 1.00 | +| DapperPure | 2.084 ms | 0.1073 ms | 1.00 | 66.4063 | - | 283.17 KB | 1.00 | +| QueryMappedSimpleRuntimeFallback | 2.110 ms | 0.2501 ms | 1.01 | 85.9375 | 19.5313 | 361.48 KB | 1.28 | +| QueryMappedImmutableConstructor | 2.134 ms | 0.3463 ms | 1.03 | 62.5000 | 11.7188 | 261.05 KB | 0.92 | +| QueryMappedSimple | 2.264 ms | 0.1914 ms | 1.09 | 62.5000 | 11.7188 | 261.12 KB | 0.92 | + +### Leitura - Steady State Final + +- A diferenca de tempo continua ruidosa no `ShortRun`; nao deve ser convertida em claim publico de latencia. +- O ganho consistente da Etapa 7 permanece em alocacao por 1000 linhas: + - simple: generated `261.12 KB` vs runtime fallback `361.48 KB`; + - nested: generated `292.44 KB` vs runtime fallback `377.06 KB`; + - Value Object: generated `276.47 KB` vs runtime fallback `587.9 KB`. +- `DapperWithFluentMapRootMapping` permanece alinhado com Dapper puro em alocacao. + +### Resultados - Cold Start + +Job: `RunStrategy=ColdStart`, `LaunchCount=8`, `WarmupCount=0`, `IterationCount=1`. + +| Method | Mean | StdDev | Ratio | Allocated | Alloc Ratio | +| --- | ---: | ---: | ---: | ---: | ---: | +| DapperPureColdStart | 238.0 ms | 50.51 ms | 1.03 | 285.95 KB | 1.00 | +| QueryMappedValueObjectColdStart | 253.0 ms | 56.23 ms | 1.10 | 645.2 KB | 2.26 | +| FluentMapRootMappingColdStart | 268.7 ms | 51.75 ms | 1.17 | 353.05 KB | 1.23 | +| QueryMappedNestedColdStart | 315.2 ms | 77.67 ms | 1.37 | 442.95 KB | 1.55 | + +### Leitura - Cold Start Final + +- Cold start continua com alta variancia e outliers, portanto serve como smoke de execucao e tendencia de alocacao. +- A rodada cold existente mede configuracao manual/runtime fallback, nao cold start generated dedicado. +- Nao foi adicionada API publica de reset apenas para medir cold generated. diff --git a/.sdd/etapa-7/FINAL-REPORT.md b/.sdd/etapa-7/FINAL-REPORT.md new file mode 100644 index 0000000..d68ffce --- /dev/null +++ b/.sdd/etapa-7/FINAL-REPORT.md @@ -0,0 +1,152 @@ +# Etapa 7 - Final Report + +## Objetivo + +Finalizar a Etapa 7 - Generated Materialization & Performance com evidencia de implementacao, regressao, performance, trimming e Native AOT, sem iniciar funcionalidades da Etapa 8 e sem declarar compatibilidade maior do que a validacao sustenta. + +## Implementado + +- Contratos publicos aditivos para materializadores gerados: + - `GeneratedRowMaterializer`; + - `GeneratedMaterializerColumn`; + - `GeneratedMaterializerDescriptor`; + - overloads `AddGeneratedMaterializer(...)` em `FluentMapConfiguration`. +- Registry interno de generated materializers por entidade, profile e shape ordenado de colunas. +- Dispatch em `QueryMapped*` antes da iteracao das linhas, com fallback para `NestedMaterializationPlan`. +- Source generator emitindo `AddGeneratedMappings()` com `AddMap()`, `AddProfile()` e materializers gerados quando o map e estaticamente suportado. +- Generated materializers para: + - propriedades flat; + - construtores root simples; + - nested mutable objects; + - nested immutable objects; + - Value Objects por componentes; + - profiles; + - ignored properties. +- Diagnostics conservadores: + - `DFM011` informativo para maps registrados mas sem materializer gerado; + - `Explain()` indica presenca de descriptors generated sem prometer dispatch para uma query especifica. + +## Arquitetura final + +`QueryMapped*` continua executando SQL pelo Dapper, le o shape ordenado do `IDataReader` e tenta localizar um generated materializer por: + +```text +EntityType + ProfileType opcional + ordered ColumnShape +``` + +Quando o descriptor existe e ainda corresponde ao mapping efetivo, o loop de linhas chama o delegate gerado. Quando nao existe match seguro, o runtime usa o `NestedMaterializationPlan` cacheado. + +O generated path e uma otimizacao de materializacao `IDataRecord -> entidade`. Ele nao gera SQL, nao cria commands, nao substitui `Dapper.Query()`, nao replica Dapper.AOT e nao remove fallback runtime. + +## Audit matrix + +| Requirement | Implementation | Tests | Benchmark | AOT/Trim impact | Status | +| --- | --- | --- | --- | --- | --- | +| Runtime fallback obrigatorio | `TryGetGeneratedMaterializer` cai para `NestedMaterializationPlan` quando descriptor falta ou diverge | `GeneratedMaterializerContractTests`, `GeneratedRegistrationIntegrationTests` | Runtime fallback comparado por shape reordenado | Mantem APIs `QueryMapped*` anotadas | Concluido | +| Lookup por entidade/profile/shape ordenado | `MaterializationPlanCacheKey` usado no registry generated | testes de default/profile/shape divergente | Benchmarks generated vs fallback por ordem diferente | Evita ordinais incorretos; shape real ainda e runtime | Concluido | +| Flat/simple generated materialization | Generator emite assignments diretos para propriedades root suportadas | generator e integracao | `QueryMappedSimple` generated vs fallback | Reduz reflection no hot path, mas lookup ainda valida metadata | Concluido | +| Constructor/immutable root | Generator seleciona construtor publico deterministico | generator, integracao e runtime fallback equivalence | `QueryMappedImmutableConstructor` | Sem `Expression.Compile` no delegate gerado | Concluido | +| Nested mutable objects | Metadata tree interna e null subtree por ordinais | generator, integracao, runtime tests | `QueryMappedNestedObject` generated vs fallback | Hot path gerado evita setters compilados | Concluido | +| Value Objects por componentes | Constructor composition bottom-up | generator, integracao, `ValueObjectMaterializationTests` | `QueryMappedValueObject` generated vs fallback | Hot path gerado evita constructor delegates runtime | Concluido | +| Profiles | Descriptor separado por `TProfile`; profile nao altera Dapper type map global | contract tests e integracao generated profile/nested profile | Coberto funcionalmente; sem benchmark separado | Registro gerado evita scanning para profiles na compilacao atual | Concluido | +| Ignored properties | Descriptor `GeneratedMaterializerColumn.Ignore(...)`; delegate nao atribui membro ignorado | generator test e integracao final 7.7 | Nao benchmarkado isoladamente | Preserva compatibilidade do mapping efetivo | Concluido | +| Duplicate member paths e nomes conflitantes | Analyzer/runtime detectam duplicidades; generator preserva member path completo | analyzer tests, same terminal tests, integracao `Rank.Level`/`Seniority.Level` | Nao benchmarkado isoladamente | Evita generated descriptor ambiguo | Concluido | +| Concurrency | Registry usa `ConcurrentDictionary`; generated delegates emitidos sem estado | contract tests de lookup/queries concorrentes | Nao benchmarkado | Sem estado mutavel por linha no generated code | Concluido | +| Diagnostics generated/fallback | `Explain()` indica descriptors registrados; `DFM011` informa fallback estatico | diagnostics tests e generator tests | Nao aplicavel | Nao promete AOT-safe por query | Concluido | +| IncludeBase gerado | Generator registra maps com `IncludeBase`, mas nao emite materializer gerado | integracao valida fallback funcional de derived map | Nao benchmarkado | Fallback runtime preservado | Adiado intencionalmente | +| Conventions/naming policies geradas | Registro/conventions funcionam; materializer gerado nao cobre fonte dinamica | runtime/analyzer existentes | Nao benchmarkado | Assembly scanning/conventions dinamicas seguem trimming-sensitive | Adiado intencionalmente | +| TypeHandlers no generated path | Scalar Value Object por TypeHandler continua no fallback runtime | `ValueObjectMaterializationTests` | Nao benchmarkado | Boundary segura com Dapper permanece em aberto | Adiado intencionalmente | +| Trimming validation | Smoke explicit e generated publicados com `PublishTrimmed=true` e executados | `Dapper.FluentMap.AotSmoke` | Nao aplicavel | Explicit: warning Dapper `IL2104`; generated: `IL2026` esperado em `QueryMapped*` + `IL2104` | Parcialmente concluido | +| Native AOT validation | Smoke criado, mas publish local bloqueado por ausencia de linker nativo | comando de publish AOT executado | Nao aplicavel | `IL2026`/`IL3050` esperados em `QueryMapped*`; execucao AOT nao validada | Bloqueado por ambiente | + +## Performance + +Benchmark final steady state, `ShortRun`, 1000 linhas por operacao: + +| Scenario | Generated final | Runtime final | Baseline 7.2 | Dapper puro | +| --- | ---: | ---: | ---: | ---: | +| Simple allocation | 261.12 KB | 361.48 KB | 361.28 KB | 283.17 KB | +| Immutable allocation | 261.05 KB | n/a final dedicado | 423.78 KB | 283.17 KB | +| Nested allocation | 292.44 KB | 377.06 KB | 376.86 KB | 283.17 KB | +| Value Object allocation | 276.47 KB | 587.9 KB | 587.7 KB | 283.17 KB | + +Leitura: + +- A evidencia mais forte e reducao de alocacao no hot path gerado. +- Tempo local permanece ruidoso; a Etapa 7 nao deve documentar promessa publica de latencia. +- `DapperWithFluentMapRootMapping` segue alinhado com Dapper puro em alocacao. + +## Native AOT / Trimming + +Validacoes executadas: + +- `dotnet publish ... -p:PublishTrimmed=true` para smoke de registro explicito: sucesso; executavel retornou `explicit:ok`; warning conhecido de Dapper `IL2104`. +- `dotnet publish ... -p:PublishTrimmed=true -p:DefineConstants=AOT_SMOKE_GENERATED` para registro gerado + generated materializer via `QueryMappedSingle`: sucesso; executavel retornou `generated:ok`. +- `dotnet publish ... -p:PublishAot=true` para smoke explicito: bloqueado por ambiente, erro "Platform linker not found". +- `dotnet publish ... -p:PublishAot=true -p:DefineConstants=AOT_SMOKE_GENERATED`: bloqueado pelo mesmo erro de linker; antes do bloqueio foram emitidos `IL2026` e `IL3050` esperados nas chamadas de `QueryMappedSingle`. + +Interpretacao: + +- Registro explicito e registro gerado sao os caminhos preferenciais para trimmed apps. +- Generated materializers executaram em publish trimmed no smoke local. +- `QueryMapped*` permanece corretamente anotado com `RequiresUnreferencedCode` e `RequiresDynamicCode`, porque ainda pode cair para fallback runtime baseado em reflection/dynamic code. +- A biblioteca nao deve ser descrita como "fully Native AOT compatible" nesta etapa. + +## Compatibilidade + +- Nenhuma API publica existente foi removida. +- APIs novas sao aditivas. +- `Dapper.Query()` e type maps globais permanecem compatíveis. +- Profiles continuam query-scoped por `QueryMapped()`. +- Dommel nao foi alterado. +- Fallback runtime preserva maps dinamicos, conventions, `IncludeBase`, TypeHandlers e shapes nao gerados. + +## Regression coverage + +Cobertura confirmada para: + +- simple mappings; +- constructors; +- nested mappings; +- immutable types; +- Value Objects; +- profiles; +- fallback por ausencia/divergencia de descriptor e por shape reordenado; +- ignored properties; +- duplicate member paths; +- member paths com mesmo terminal; +- concurrency de lookup e queries generated; +- analyzers para duplicidade e registros invalidos; +- smoke de trimming explicito e gerado. + +## Limitações conhecidas + +- `QueryMapped*` continua bufferizado. +- Generated materializers cobrem apenas o subconjunto estatico suportado da DSL. +- Query shapes extras, ausentes ou reordenados usam fallback runtime. +- `IncludeBase()`, conventions customizadas, naming policies como fonte gerada e TypeHandlers permanecem no fallback. +- O lookup generated ainda valida descriptor contra mapping efetivo por query, usando metadata runtime. +- Native AOT publish/run nao foi validado localmente por ausencia do linker nativo. + +## Dívidas técnicas + +- Medir cold start generated sem criar API publica de reset apenas para benchmark. +- Melhorar diagnostico por query/shape sem acoplar `Explain()` a SQL. +- Avaliar uma boundary publica segura para TypeHandlers no generated path. +- Reduzir dependencia de metadata runtime no lookup generated, se houver caminho compativel e seguro. + +## Itens adiados + +- Generated materializers para `IncludeBase()`. +- Generated materializers para conventions/naming policies built-in configuradas estaticamente. +- Manifests de assemblies referenciados. +- API de diagnostico de dispatch por column shape. +- Suporte generated para TypeHandlers. +- Declaracao de compatibilidade Native AOT alem dos cenarios validados. + +## Recomendações para Etapa 8 + +- Priorizar diagnostico publico por shape antes de ampliar cobertura gerada. +- Investigar generated cold start e isolamento de caches sem expor reset perigoso. +- Separar qualquer trabalho de Native AOT em matriz CI com toolchain nativa instalada. +- Manter TypeHandler/generated boundary como design dedicado, com testes de Dapper real. diff --git a/.sdd/etapa-7/STATUS.md b/.sdd/etapa-7/STATUS.md index b1e7818..0340c1f 100644 --- a/.sdd/etapa-7/STATUS.md +++ b/.sdd/etapa-7/STATUS.md @@ -1,5 +1,26 @@ # Etapa 7 Status +Status final: CONCLUIDA COM LIMITACOES DOCUMENTADAS +Ultimo prompt executado: 7.7 + +## Fechamento 7.7 + +- Criado `.sdd/etapa-7/FINAL-REPORT.md` com matriz Requirement / Implementation / Tests / Benchmark / AOT/Trim impact / Status. +- Atualizado `.sdd/etapa-7/02-performance-baseline.md` com `Resultados finais da Etapa 7`. +- Atualizado `README.md` para reforcar que `QueryMapped*` mantem annotations de trimming/dynamic code mesmo quando generated materializer existe. +- Endurecido o smoke AOT para executar generated materializer real via `QueryMappedSingle` no modo `AOT_SMOKE_GENERATED`. +- Adicionada regressao de ignored properties no generator e no teste de integracao de generated registration. +- Validado publish trimmed explicito: sucesso, executavel retornou `explicit:ok`; warning conhecido `IL2104` em Dapper. +- Validado publish trimmed gerado: sucesso, executavel retornou `generated:ok`; warnings esperados `IL2026` nas chamadas deliberadas de `QueryMappedSingle` e `IL2104` em `Dapper.FluentMap`/Dapper. +- Tentado publish Native AOT explicito e gerado: bloqueado pelo ambiente por ausencia do platform linker / Visual Studio C++ workload. Nao foi declarada compatibilidade Native AOT completa. +- Build Release da solution: sucesso, 0 warnings, 0 errors. +- Testes Release da solution: sucesso, 254 testes aprovados. +- Benchmarks finais steady state e cold start executados e registrados em `02-performance-baseline.md`. + +## Estado final + +A Etapa 7 esta concluida para o escopo implementavel neste ambiente: generated materialization foi integrada com fallback runtime, coberta por regressao, medida em benchmark local e validada em publish trimmed. Native AOT permanece uma limitacao de validacao de ambiente e nao deve ser comunicado como suporte completo. + ## Objetivo Definir a arquitetura e a especificacao inicial para materializacao gerada no FluentMap, preservando o escopo da biblioteca em `IDataReader / IDataRecord -> metadata de mapping -> object graph` e mantendo fallback runtime. diff --git a/README.md b/README.md index 3425789..2366ee5 100644 --- a/README.md +++ b/README.md @@ -339,7 +339,7 @@ FluentMap has different levels of support depending on the API: | 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. +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. `QueryMapped*` keeps its trimming and dynamic-code annotations even when a generated materializer is available, because unsupported shapes can still fall back to the runtime materializer. ## Dapper Integration @@ -762,7 +762,7 @@ FluentMap tem níveis diferentes de suporte conforme a API: | 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. +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. `QueryMapped*` mantém suas anotações de trimming e dynamic code mesmo quando um materializador gerado existe, porque shapes não suportados ainda podem cair para o materializador runtime. ## Integração com Dapper diff --git a/test/Dapper.FluentMap.AotSmoke/Dapper.FluentMap.AotSmoke.csproj b/test/Dapper.FluentMap.AotSmoke/Dapper.FluentMap.AotSmoke.csproj index d870c52..9086546 100644 --- a/test/Dapper.FluentMap.AotSmoke/Dapper.FluentMap.AotSmoke.csproj +++ b/test/Dapper.FluentMap.AotSmoke/Dapper.FluentMap.AotSmoke.csproj @@ -13,6 +13,8 @@ ..\..\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 8c0c364..7299449 100644 --- a/test/Dapper.FluentMap.AotSmoke/Program.cs +++ b/test/Dapper.FluentMap.AotSmoke/Program.cs @@ -5,6 +5,7 @@ using Dapper.FluentMap.Diagnostics; using Dapper.FluentMap.Mapping; using Dapper.FluentMap.Naming; +using Microsoft.Data.Sqlite; #if AOT_SMOKE_GENERATED const string scenario = "generated"; @@ -20,6 +21,7 @@ AssertExplain(); AssertValueObjectExplain(); AssertProfileExplain(); +AssertGeneratedQueryMappedMaterializer(); #elif AOT_SMOKE_SCANNING const string scenario = "scanning"; FluentMapper.Initialize(configuration => configuration.AddMapsFromAssemblyContaining()); @@ -112,6 +114,30 @@ static void AssertProfileExplain() } #endif +#if AOT_SMOKE_GENERATED +static void AssertGeneratedQueryMappedMaterializer() +{ + SQLitePCL.Batteries_V2.Init(); + + using var connection = new SqliteConnection("Data Source=:memory:"); + connection.Open(); + + var customer = connection.QueryMappedSingle( + "SELECT 42 AS customer_id;"); + if (customer.Id != 42) + { + throw new InvalidOperationException("Generated flat QueryMapped materializer was not used correctly."); + } + + var valueObjectCustomer = connection.QueryMappedSingle( + "SELECT '12345678909' AS cpf;"); + if (valueObjectCustomer.Cpf?.Number != "12345678909") + { + throw new InvalidOperationException("Generated Value Object QueryMapped materializer was not used correctly."); + } +} +#endif + public sealed class Customer { public int Id { get; set; } diff --git a/test/Dapper.FluentMap.GeneratedRegistration.Tests/GeneratedRegistrationIntegrationTests.cs b/test/Dapper.FluentMap.GeneratedRegistration.Tests/GeneratedRegistrationIntegrationTests.cs index 7631f17..6c91e6e 100644 --- a/test/Dapper.FluentMap.GeneratedRegistration.Tests/GeneratedRegistrationIntegrationTests.cs +++ b/test/Dapper.FluentMap.GeneratedRegistration.Tests/GeneratedRegistrationIntegrationTests.cs @@ -53,6 +53,8 @@ public void GeneratedRegistrationShouldWorkWithDapperAndExistingMappingFeatures( "SELECT 5 AS rank_level, 9 AS seniority_level;"); var profiledNested = connection.QueryMappedSingle( "SELECT 'Profile City' AS legacy_city;"); + var ignored = connection.QueryMappedSingle( + "SELECT 17 AS customer_id, 'do-not-map' AS secret;"); Assert.Equal(7, customer.Id); Assert.Equal("Ada", customer.Name); @@ -80,6 +82,8 @@ public void GeneratedRegistrationShouldWorkWithDapperAndExistingMappingFeatures( Assert.Equal(5, sameTerminal.Rank.Level); Assert.Equal(9, sameTerminal.Seniority.Level); Assert.Equal("Profile City", profiledNested.Address.City); + Assert.Equal(17, ignored.Id); + Assert.Equal("initial", ignored.Secret); Assert.Equal(0, FluentMapper.Registry.MaterializationPlanCacheEntryCount); var fallback = connection.QueryMappedSingle( @@ -191,7 +195,8 @@ private static void ResetMapper() typeof(GeneratedNestedCustomer), typeof(GeneratedValueObjectCustomer), typeof(GeneratedSameTerminalCustomer), - typeof(GeneratedProfileNestedCustomer)); + typeof(GeneratedProfileNestedCustomer), + typeof(GeneratedIgnoredCustomer)); } } @@ -422,4 +427,20 @@ public GeneratedLegacyProfileNestedCustomerMap() Map(customer => customer.Address.City).ToColumn("legacy_city"); } } + + public sealed class GeneratedIgnoredCustomer + { + public int Id { get; set; } + + public string Secret { get; set; } = "initial"; + } + + public sealed class GeneratedIgnoredCustomerMap : EntityMap + { + public GeneratedIgnoredCustomerMap() + { + Map(customer => customer.Id).ToColumn("customer_id"); + Map(customer => customer.Secret).ToColumn("secret").Ignore(); + } + } } diff --git a/test/Dapper.FluentMap.Generators.Tests/MappingRegistrationGeneratorTests.cs b/test/Dapper.FluentMap.Generators.Tests/MappingRegistrationGeneratorTests.cs index ccbcf97..71330e6 100644 --- a/test/Dapper.FluentMap.Generators.Tests/MappingRegistrationGeneratorTests.cs +++ b/test/Dapper.FluentMap.Generators.Tests/MappingRegistrationGeneratorTests.cs @@ -167,6 +167,37 @@ public CustomerMap() Assert.Contains("return default(T);", result.GeneratedSource, StringComparison.Ordinal); } + [Fact] + public void IgnoredPropertiesShouldGenerateIgnoredColumnDescriptor() + { + var source = @" +using Dapper.FluentMap.Mapping; + +public sealed class Customer +{ + public int Id { get; set; } + + public string Secret { get; set; } +} + +public sealed class CustomerMap : EntityMap +{ + public CustomerMap() + { + Map(customer => customer.Id).ToColumn(""customer_id""); + Map(customer => customer.Secret).ToColumn(""secret"").Ignore(); + } +}"; + + var result = RunGenerator(source); + + Assert.Empty(result.DfmDiagnostics); + Assert.Contains("GeneratedMaterializerColumn.Map(\"customer_id\", \"Id\")", result.GeneratedSource, StringComparison.Ordinal); + Assert.Contains("GeneratedMaterializerColumn.Ignore(\"secret\")", result.GeneratedSource, StringComparison.Ordinal); + Assert.Contains("entity.Id = Read(record, 0);", result.GeneratedSource, StringComparison.Ordinal); + Assert.DoesNotContain("entity.Secret =", result.GeneratedSource, StringComparison.Ordinal); + } + [Fact] public void MultipleMappingsShouldBeGeneratedInDeterministicOrder() { From 664d74a83f4341e40e191401caecf4f6261b5053 Mon Sep 17 00:00:00 2001 From: Rodrigo de Oliveira Date: Tue, 28 Jul 2026 11:13:57 -0300 Subject: [PATCH 08/49] docs(sdd): define persistence semantics architecture --- .sdd/etapa-8/01-historical-issues.md | 403 ++++++++++++++++++ .sdd/etapa-8/02-persistence-semantics-spec.md | 367 ++++++++++++++++ .sdd/etapa-8/DECISIONS.md | 186 ++++++++ .sdd/etapa-8/STATUS.md | 105 +++++ 4 files changed, 1061 insertions(+) create mode 100644 .sdd/etapa-8/01-historical-issues.md create mode 100644 .sdd/etapa-8/02-persistence-semantics-spec.md create mode 100644 .sdd/etapa-8/DECISIONS.md create mode 100644 .sdd/etapa-8/STATUS.md diff --git a/.sdd/etapa-8/01-historical-issues.md b/.sdd/etapa-8/01-historical-issues.md new file mode 100644 index 0000000..0aa093e --- /dev/null +++ b/.sdd/etapa-8/01-historical-issues.md @@ -0,0 +1,403 @@ +# Etapa 8 - Historical Issue Assessment + +Este documento registra a leitura historica das issues do projeto original +`henkmollema/Dapper-FluentMap` e o estado observado no fork atual em +2026-07-28. + +## Issue #94 - ReadOnly Fields + +Fonte: https://github.com/henkmollema/Dapper-FluentMap/issues/94 + +### Problema original + +O usuario pediu um equivalente a `Ignore()` para campos somente leitura. A +necessidade descrita nos comentarios foi popular uma propriedade por query, mas +nao tentar grava-la em `INSERT` ou `UPDATE`. Exemplos citados: campo computado, +contador e campo `Created` com default do banco. + +### Causa provavel/historica + +O FluentMap historico tinha `Ignore()` como unico vocabulario para excluir uma +propriedade. Isso misturava duas intencoes diferentes: + +- nao materializar uma coluna de leitura; +- nao persistir uma propriedade em comandos de escrita gerados pelo Dommel. + +Como `Ignore()` tambem impede leitura, ele nao representava "read-only database +value". + +### Estado no fork atual + +O core ainda expoe apenas `IPropertyMap.Ignored` para exclusao. Em +`DapperFluentPropertyTypeMap.GetMember`, maps ignorados retornam +`DapperIgnoredMemberMap`; em `NestedMaterializationPlan.Create`, maps ignorados +sao pulados; em generated materializers, `GeneratedMaterializerColumn.Ignore` +representa coluna que deve ser ignorada pela configuracao efetiva. + +Dommel possui metadata propria em `DommelPropertyMap`: + +- `Key`; +- `Identity`; +- `GeneratedOption`. + +Nao ha API de core para "read yes, insert no, update no". + +### Ainda reproduzivel? + +Sim, como lacuna de modelo: nao existe API publica no core para "ler, mas nao +inserir/atualizar". O comportamento pode ser contornado no pacote Dommel com +`SetGeneratedOption(...)`, mas isso nao e semantica clara de read-only no core e +nao cobre expressivamente "exclude insert" versus "exclude update". + +### Cobertura de testes existente + +- `ManualMappingTests.PropertyShouldBeIgnored` cobre o flag `Ignored`. +- `GeneratedRegistrationIntegrationTests` cobre ignored no generated path. +- Testes Dommel cobrem key/generated basicos, mas nao o caso read-only como + semantica independente. + +### Relacao com a Etapa 8 + +E a issue que melhor expressa o objetivo central: separar leitura/materializacao +de persistencia escrita. + +### Decisao + +Resolver por nova arquitetura. + +### Evidencia + +- `src/Dapper.FluentMap/Mapping/PropertyMap.cs` +- `src/Dapper.FluentMap/Compatibility/DapperFluentPropertyTypeMap.cs` +- `src/Dapper.FluentMap/Materialization/NestedMaterializationPlan.cs` +- `src/Dapper.FluentMap/Materialization/GeneratedMaterializerColumn.cs` + +## Issue #122 - Insert issue when key column is not identity + +Fonte: https://github.com/henkmollema/Dapper-FluentMap/issues/122 + +PR relacionado: https://github.com/henkmollema/Dapper-FluentMap/pull/129 + +### Problema original + +Depois do upgrade para v2, keys nao auto-geradas eram omitidas do `INSERT`. +Mesmo usando `SetGeneratedOption(DatabaseGeneratedOption.None)`, o SQL gerado +pelo Dommel omitia as colunas key e ainda tentava buscar identity. + +### Causa provavel/historica + +O modelo Dommel tratava `IsKey()` como implicando `DatabaseGeneratedOption.Identity` +por default. O contrato do Dommel tambem usa `ColumnPropertyInfo.IsGenerated` para +filtrar valores de insert/update. Quando "key" e "identity" se confundem, uma +key de negocio ou composite key deixa de ser inserida. + +### Estado no fork atual + +O fork contem a alteracao equivalente ao PR #129: + +- `DommelPropertyMap.GeneratedOption`; +- `SetGeneratedOption(DatabaseGeneratedOption option)`; +- resolvers Dommel criando `ColumnPropertyInfo` com + `GeneratedOption ?? (Key ? Identity : None)`. + +Os testes `EntityMapsToMultipleKeys` e `PropertiesAreNotGenerated` validam +composite keys com `DatabaseGeneratedOption.None`. + +### Ainda reproduzivel? + +Parcialmente nao para o caso coberto por `SetGeneratedOption(None)` em composite +keys. A fragilidade arquitetural permanece: por default, `IsKey()` ainda implica +identity/generated, entao key e identity continuam conceitualmente acoplados +quando o usuario nao explicita `None`. + +### Cobertura de testes existente + +- `test/Dapper.FluentMap.Dommel.Tests/ManualMappingTests.cs` + - `EntityMapsToMultipleKeys`; + - `PropertiesAreNotGenerated`; + - `KeyPropertyIsGenerated`. + +### Relacao com a Etapa 8 + +Exige modelar `Key` e `Identity` como dimensoes independentes. Uma key nao +identity deve ser representavel como `Read=yes`, `Insert=yes`, `Key=yes`, +`Identity=no`. + +### Decisao + +Resolver por nova arquitetura e manter regression tests. + +### Evidencia + +- `src/Dapper.FluentMap.Dommel/Mapping/DommelPropertyMap.cs` +- `src/Dapper.FluentMap.Dommel/Resolvers/DommelKeyPropertyResolver.cs` +- `src/Dapper.FluentMap.Dommel/Resolvers/DommelPropertyResolver.cs` +- Dommel 3.5.3 `ColumnPropertyInfo.IsGenerated`: + https://github.com/henkmollema/Dommel/blob/master/src/Dommel/ColumnPropertyInfo.cs + +## Issue #123 - Computed property used in insert/update + +Fonte: https://github.com/henkmollema/Dapper-FluentMap/issues/123 + +### Problema original + +`SetGeneratedOption(DatabaseGeneratedOption.Computed)` em uma propriedade +continuava gerando coluna e parametro em `INSERT` e `UPDATE` pelo Dommel. +O usuario esperava que coluna computada fosse omitida dos comandos de escrita, +mas continuasse disponivel para leitura. + +### Causa provavel/historica + +O resolver de propriedades do FluentMap.Dommel nao entregava a opcao generated +para o `ColumnPropertyInfo` usado pelo Dommel, ou o Dommel efetivo nao consumia +essa metadata como esperado. A consequencia era que "computed" nao virava +"exclude insert/update" no SQL gerado. + +### Estado no fork atual + +O `DommelPropertyResolver` atual passa `dommelPropertyMap.GeneratedOption` para +`ColumnPropertyInfo`. No Dommel 3.5.3, `BuildInsertQuery` e `BuildUpdateQuery` +filtram `Resolvers.Properties(type).Where(x => !x.IsGenerated)`. Portanto, se +`Computed` chegar ao `ColumnPropertyInfo`, insert/update devem omitir a coluna. + +O fork, porem, nao tem teste de integracao de SQL real para computed/default com +Dommel `Insert` e `Update`. + +### Ainda reproduzivel? + +Provavelmente nao para metadata `Computed` no resolver atual, mas ainda nao ha +regressao direta com SQL gerado/executado no fork. Deve ser tratado como risco +historico ate haver teste especifico. + +### Cobertura de testes existente + +- `PropertyIsGenerated` valida que alguma propriedade gerada aparece como + `IsGenerated`. +- Nao ha teste especifico para `DatabaseGeneratedOption.Computed` em `Insert` e + `Update`. + +### Relacao com a Etapa 8 + +Computed deve ser uma semantica de escrita, nao sinonimo de ignore. A decisao +deve dizer que computed normalmente e `Read=yes`, `Insert=no`, `Update=no`, +`Generated=yes`, `Computed=yes`. + +### Decisao + +Resolver por nova arquitetura e adicionar regression test historico em prompt +posterior. + +### Evidencia + +- `src/Dapper.FluentMap.Dommel/Resolvers/DommelPropertyResolver.cs` +- Dommel `Insert.cs` e `Update.cs` filtram `IsGenerated`: + https://github.com/henkmollema/Dommel/blob/master/src/Dommel/Insert.cs + https://github.com/henkmollema/Dommel/blob/master/src/Dommel/Update.cs + +## Issue #130 - Default value do banco vs Ignore() + +Fonte: https://github.com/henkmollema/Dapper-FluentMap/issues/130 + +### Problema original + +Em SQLite, uma coluna `datetime` com default do banco funcionava no insert quando +a propriedade era marcada com `Ignore()`, mas a leitura falhava com +`NotImplementedException` porque a propriedade era ignorada. Ao tentar +`DatabaseGeneratedOption.Identity` ou `Computed`, a propriedade voltava com +`0001-01-01 00:00:00`. + +### Causa provavel/historica + +O usuario queria "omit on insert, read on select". O unico mecanismo intuitivo +era `Ignore()`, que representa "nao mapear" no core. A alternativa via +`GeneratedOption` dependia do Dommel e nao comunicava claramente se o valor +deveria ser omitido so no insert, tambem no update, e como seria relido. + +### Estado no fork atual + +O bug especifico de `NotImplementedException` para `Ignore()` foi tratado pelo +PR #131 no projeto original e o fork atual usa `DapperIgnoredMemberMap` sem +`PropertyInfo` incompleto. O problema conceitual permanece: default de banco no +insert nao deve exigir `Ignore()` porque a propriedade ainda participa da +leitura. + +### Ainda reproduzivel? + +O `NotImplementedException` provavelmente nao. A lacuna "database default on +insert" ainda e reproduzivel como ausencia de semantica explicita no core. + +### Cobertura de testes existente + +- Generated ignored property tem regressao no fork. +- Nao ha teste Dommel para default on insert com leitura posterior. + +### Relacao com a Etapa 8 + +Motiva uma semantica conceitual do tipo `Read=yes`, `Insert=no`, `Update=yes` +ou `Update=no`, conforme decisao explicita por API futura. + +### Decisao + +Resolver por nova arquitetura. + +### Evidencia + +- `src/Dapper.FluentMap/Compatibility/DapperIgnoredMemberMap.cs` +- `test/Dapper.FluentMap.GeneratedRegistration.Tests/GeneratedRegistrationIntegrationTests.cs` +- PR #131: https://github.com/henkmollema/Dapper-FluentMap/pull/131 + +## Issue #114 - Conflict between property and members of the type + +Fonte: https://github.com/henkmollema/Dapper-FluentMap/issues/114 + +### Problema original + +Mapear uma propriedade chamada `Format` causava `InvalidCastException` porque o +helper historico buscava membro por nome no tipo do parametro e pegava primeiro +um metodo do BCL em vez da propriedade correta. Comentario posterior citou +`TimeSpan.Duration`. + +### Causa provavel/historica + +Expression parsing por reflection procurava membro por nome em vez de usar o +`MemberInfo` real da expressao e validar que era propriedade. + +### Estado no fork atual + +`ReflectionHelper.GetMemberPath` caminha a expression tree, aceita conversoes de +`Expression>`, usa o `MemberExpression.Member` real e +rejeita membros nao propriedade com `ArgumentException`. + +### Ainda reproduzivel? + +Nao para os cenarios cobertos: existem testes para `Format`, `Duration` e +colisao com membro de `string`. + +### Cobertura de testes existente + +- `ReflectionHelperTests.GetMemberInfo_ReturnsProperty_WhenPropertyNameMatchesSystemMember` +- `ReflectionHelperTests.GetMemberInfo_ReturnsValueTypeProperty_WhenPropertyNameMatchesSystemMember` +- `ReflectionHelperTests.GetMemberInfo_ReturnsValueTypeProperty_WithSystemTypeNames` + +### Relacao com a Etapa 8 + +Regression historico a preservar. Qualquer nova metadata de persistencia deve +ser associada ao `PropertyInfo`/`MemberPath` real, nunca ao primeiro membro por +nome. + +### Decisao + +Ja resolvido; manter como regression boundary. + +### Evidencia + +- `src/Dapper.FluentMap/Utils/ReflectionHelper.cs` +- `test/Dapper.FluentMap.Tests/ReflectionHelperTests.cs` + +## Issue #126 - Nested properties ending with same name + +Fonte: https://github.com/henkmollema/Dapper-FluentMap/issues/126 + +### Problema original + +Mapear varios caminhos aninhados terminando em `Level` era tratado como +duplicidade, por exemplo `Rank.Level`, `Seniority.Level`, +`CompletedProfile.Level`. + +### Causa provavel/historica + +O sistema historico identificava maps pelo `PropertyInfo` terminal ou pelo nome +terminal, nao pelo caminho completo. + +### Estado no fork atual + +O fork introduziu `MemberPath` e `PropertyMapIdentity`. Duplicidade e validada +por caminho completo. Materializacao runtime, generated materializers e analyzers +preservam o display completo (`Rank.Level`, `Seniority.Level`). + +### Ainda reproduzivel? + +Nao para os cenarios cobertos no core e no generated path. O resolver de colunas +do Dommel ainda usa `PropertyInfo.Name`, mas Dommel nao suporta nested +materialization e os resolvers Dommel so trabalham com propriedades flat +filtradas por `type.GetProperties()`. + +### Cobertura de testes existente + +- `ManualMappingTests.PropertyMapShouldDistinguishNestedPropertiesWithSameTerminalName` +- `NestedObjectMaterializationTests.QueryMappedShouldPreserveSameTerminalMemberPaths` +- `MappingRegistrationGeneratorTests.SameTerminalNestedPathsShouldUseFullMemberPathsInDescriptor` +- `GeneratedRegistrationIntegrationTests` para same terminal. + +### Relacao com a Etapa 8 + +Nova metadata de persistencia deve ser anexada ao caminho de membro completo +quando houver nested mapping, mesmo que Dommel inicialmente consuma apenas maps +flat. + +### Decisao + +Ja resolvido no core/generated; apenas regression test se a etapa tocar nessa +area. + +### Evidencia + +- `src/Dapper.FluentMap/Mapping/MemberPath.cs` +- `src/Dapper.FluentMap/Mapping/PropertyMapIdentity.cs` +- `test/Dapper.FluentMap.Tests/NestedObjectMaterializationTests.cs` +- `test/Dapper.FluentMap.Generators.Tests/MappingRegistrationGeneratorTests.cs` + +## Issue #133 - Ignore() causing NotImplementedException + +Fonte: https://github.com/henkmollema/Dapper-FluentMap/issues/133 + +PR relacionado: https://github.com/henkmollema/Dapper-FluentMap/pull/131 + +### Problema original + +Selecionar uma entidade com propriedade marcada como `Ignore()` causava +`NotImplementedException` no Dapper. O stack trace historico do PR #131 mostra +um `IgnoredPropertyInfo.PropertyType` nao implementado sendo acessado pelo +deserializador do Dapper. + +### Causa provavel/historica + +O ignore era representado por um `PropertyInfo` falso/incompleto. Dapper ainda +tentava consultar `MemberType` e acabava chamando membro nao implementado. + +### Estado no fork atual + +O core usa `DapperIgnoredMemberMap`, um `SqlMapper.IMemberMap` sentinela com +`MemberType = typeof(object)` e `Property`, `Field`, `Parameter` nulos. O +`MultiTypeMap` atual consegue tratar esse sentinela sem expor um `PropertyInfo` +incompleto. + +### Ainda reproduzivel? + +Provavelmente nao no core atual; existem regressões para ignored no generated +path e testes basicos de ignore. Ainda seria util adicionar regression historico +especifico para `Dapper.Query()` com coluna ignorada e propriedades get-only, +se a etapa alterar a area. + +### Cobertura de testes existente + +- `ManualMappingTests.PropertyShouldBeIgnored` +- `GeneratedRegistrationIntegrationTests` valida que `Secret` ignorado permanece + com valor inicial. + +### Relacao com a Etapa 8 + +Mostra por que `Ignore()` nao deve ser reaproveitado para "read-only" ou +"default on insert". `Ignore()` deve continuar significando ausencia completa de +participacao em leitura e escrita. + +### Decisao + +Ja resolvido para o bug original; manter regression boundary e nao reutilizar +`Ignore()` para semantica de escrita. + +### Evidencia + +- `src/Dapper.FluentMap/Compatibility/DapperIgnoredMemberMap.cs` +- `src/Dapper.FluentMap/Compatibility/DapperFluentPropertyTypeMap.cs` +- PR #131: https://github.com/henkmollema/Dapper-FluentMap/pull/131 diff --git a/.sdd/etapa-8/02-persistence-semantics-spec.md b/.sdd/etapa-8/02-persistence-semantics-spec.md new file mode 100644 index 0000000..ef9910f --- /dev/null +++ b/.sdd/etapa-8/02-persistence-semantics-spec.md @@ -0,0 +1,367 @@ +# Etapa 8 - Persistence Semantics Specification + +Status: especificacao inicial, sem implementacao produtiva. + +## Objetivo + +Definir um modelo coerente para propriedades que participam de leitura e escrita +de formas diferentes, preservando o escopo do FluentMap: + +- o core descreve mapping e metadata; +- o core nao gera SQL e nao executa CRUD; +- o pacote Dommel pode consumir metadata de persistencia para seus comandos. + +## Discovery do comportamento atual + +### Core FluentMap + +`PropertyMap` contem hoje: + +- `ColumnName`; +- `CaseSensitive`; +- `Ignored`; +- `PropertyInfo`; +- `MemberPath` interno. + +`Ignore()` significa "nao mapear este membro para leitura/materializacao": + +- `DapperFluentPropertyTypeMap.GetMember` retorna `DapperIgnoredMemberMap` para + maps ignorados; +- `NestedMaterializationPlan.Create` pula maps ignorados; +- generated materializers registram `GeneratedMaterializerColumn.Ignore`; +- diagnostics expõem `MemberMappingExplanation.Ignored`. + +Nao ha dimensoes separadas de `Insert`, `Update`, `Generated`, `Computed`, +`Default` ou `Identity` no core. + +### Dommel integration + +`DommelPropertyMap` adiciona metadata especifica: + +- `Key`; +- `Identity`; +- `GeneratedOption`. + +Os resolvers atuais passam `DatabaseGeneratedOption` para Dommel: + +- `DommelPropertyResolver.ResolveProperties` exclui `Ignored` e cria + `ColumnPropertyInfo(property, generatedOption)`; +- `DommelKeyPropertyResolver.ResolveKeyProperties` usa maps marcados como + `Key`, mais fallback de key default do Dommel; +- `DommelColumnNameResolver` resolve column name por propriedade flat; +- `DommelTableNameResolver` resolve table name. + +No Dommel 3.5.3, `Insert` e `Update` constroem SQL a partir de +`Resolvers.Properties(type).Where(x => !x.IsGenerated)`, e usam key properties +separadamente para identity/where. Portanto, a integracao atual depende de +`ColumnPropertyInfo.IsGenerated`. + +### Materializacao, constructor mapping, nested mappings, generated path e profiles + +Esses caminhos pertencem a leitura: + +- `Dapper.Query()` usa type map global para root-level mapping; +- `QueryMapped*` usa runtime materializer ou generated materializer; +- profiles sao query-scoped apenas para `QueryMapped()`; +- nested e Value Object mapping sao materializacao de leitura; +- generated materializers descrevem shape de leitura `IDataRecord -> entity`. + +Nenhum desses caminhos deve decidir se uma propriedade entra em `INSERT` ou +`UPDATE`. + +## Modelo conceitual + +A propriedade deve ser descrita por dimensoes independentes. Esta lista e +conceitual e nao implica API publica direta: + +| Dimensao | Pergunta | Consumidor primario | +| --- | --- | --- | +| `Read` | A coluna pode materializar esse membro? | Dapper type maps, `QueryMapped*`, generated materializers | +| `Insert` | A propriedade pode ser enviada em `INSERT` gerado? | Dommel | +| `Update` | A propriedade pode ser enviada no `SET` de `UPDATE` gerado? | Dommel | +| `Generated` | O banco pode gerar o valor em alguma operacao? | Dommel, diagnostics | +| `Key` | A propriedade identifica a linha? | Dommel key resolver | +| `Identity` | A key/coluna e gerada como identity no insert? | Dommel insert result/key handling | +| `Computed` | O valor e computado pelo banco e nao deve ser escrito? | Dommel insert/update filtering | +| `DefaultOnInsert` | O banco aplica default quando a coluna e omitida no insert? | Dommel insert filtering | + +Regras de independencia: + +- `Ignored` equivale a `Read=no`, `Insert=no`, `Update=no`. +- `ReadOnly` nao equivale a `Ignored`. +- `Key` nao implica `Identity`. +- `Identity` implica `Generated` e normalmente `Insert=no`. +- `Computed` implica `Generated`, `Insert=no`, `Update=no`, `Read=yes`. +- `DefaultOnInsert` implica `Insert=no`, mas nao determina sozinho `Update`. +- `Generated` sozinho e insuficiente como API final se nao disser em qual + operacao a escrita deve ser omitida. + +## Casos semanticos obrigatorios + +### Normal property + +```text +Read = yes +Insert = yes +Update = yes +Generated = no +Key = no +Identity = no +Computed = no +``` + +### Ignored property + +```text +Read = no +Insert = no +Update = no +``` + +`Ignore()` deve preservar esse significado por compatibilidade. + +### Read-only database value + +```text +Read = yes +Insert = no +Update = no +``` + +Representa campos como `CreatedAt`, row metadata, contador projetado ou valor +calculado que o modelo deve receber, mas o comando gerado nao deve escrever. + +### Database default on insert + +Semantica recomendada: + +```text +Read = yes +Insert = no +Update = yes +DefaultOnInsert = yes +Generated = yes +``` + +Justificativa: o default do banco e aplicado quando a coluna e omitida no +`INSERT`; depois disso, a aplicacao pode ou nao atualizar o valor. Como existem +dominios em que o default e somente valor inicial e outros em que o valor tambem +deve permanecer read-only, a API futura deve permitir compor `ExcludeFromInsert` +e `ExcludeFromUpdate`. + +### Computed property + +```text +Read = yes +Insert = no +Update = no +Generated = yes +Computed = yes +``` + +Computed nao deve ser alias para `Ignore()`, porque a coluna pode ser lida. + +### Identity key + +```text +Read = yes +Insert = no +Update = no +Key = yes +Identity = yes +Generated = yes +``` + +A key identifica a linha. O valor identity e gerado pelo banco no insert. +O update da key deve ser proibido por default. + +### Non-identity key + +```text +Read = yes +Insert = yes +Update = no +Key = yes +Identity = no +Generated = no +``` + +Decisao inicial: key nao identity participa de `INSERT`, mas nao entra no `SET` +de `UPDATE`; ela entra no `WHERE`. Isso combina com o comportamento usual do +Dommel e evita alterar identidade logica da linha por acidente. + +### Generated non-key value + +```text +Read = yes +Insert = no +Update = conforme subtipo +Generated = yes +Key = no +``` + +`Generated` deve ser tratado como categoria guarda-chuva. A API publica deve +preferir metodos mais especificos quando possivel. + +## API design exploration + +### Opcao A - `ReadOnly()` + +```csharp +Map(x => x.CreatedAt) + .ToColumn("created_at") + .ReadOnly(); +``` + +Vantagens: + +- discoverability alta para #94; +- comunica "ler, nao escrever"; +- facil de mapear para `Insert=no` e `Update=no`. + +Custos: + +- pode ser ambigua com propriedade C# sem setter; +- nao expressa default somente no insert; +- nome pode sugerir restricao de imutabilidade do objeto, nao persistencia. + +### Opcao B - exclusoes por operacao + +```csharp +Map(x => x.CreatedAt) + .ToColumn("created_at") + .ExcludeFromInsert() + .ExcludeFromUpdate(); +``` + +Vantagens: + +- composavel; +- representa default on insert; +- evita sobrecarregar `Generated`; +- claro para Dommel. + +Custos: + +- mais verboso; +- usuarios precisam conhecer insert/update semantics. + +### Opcao C - metodos de dominio de persistencia + +```csharp +Map(x => x.RowVersion).Computed(); +Map(x => x.CreatedAt).DatabaseDefaultOnInsert(); +Map(x => x.Id).IsKey().IsIdentity(); +Map(x => x.Code).IsKey().Assigned(); +``` + +Vantagens: + +- expressa intencao; +- melhora diagnostics/analyzers; +- pode traduzir para flags operacionais. + +Custos: + +- aumenta superficie publica; +- parte dos nomes e fortemente associada a CRUD/Dommel; +- risco de transformar o core em mini ORM se mal delimitado. + +### Opcao D - metadata no core, API especializada no Dommel + +Core adiciona um contrato de metadata de persistencia em `IPropertyMap` ou +interface opcional, e Dommel oferece metodos fluent que preenchem essa metadata. + +Vantagens: + +- core continua sem CRUD; +- Dommel pode consumir a metadata sem duplicar parsing de maps; +- generated/analyzers podem entender metadata quando ela existir; +- compatibilidade pode ser aditiva via interface opcional. + +Custos: + +- exige desenho cuidadoso para nao quebrar implementacoes customizadas de + `IPropertyMap`; +- consumidores do core podem perguntar por metadata que nao usam. + +## Decisao de localizacao + +Semantica pertence a uma abstracao de metadata no core, consumida opcionalmente +pelo Dommel. + +Racional: + +- a propriedade ja e descrita no core por coluna, case sensitivity, ignore e + member path; +- a distincao `Read` versus `Insert`/`Update` e conceitual, nao exclusivamente + Dommel; +- o core nao deve gerar SQL, mas pode descrever metadata; +- Dommel deve continuar responsavel por traduzir metadata para comandos CRUD. + +## Forma recomendada para API futura + +Nao implementar neste prompt. Direcao preferida para prompts seguintes: + +1. Introduzir metadata interna/aditiva de persistencia com defaults compativeis: + `Read=yes`, `Insert=yes`, `Update=yes`, `Key=no`, `Generated=no`. +2. Preservar `Ignore()` como `Read=no`, `Insert=no`, `Update=no`. +3. Oferecer API composavel por operacao: + `ExcludeFromInsert()`, `ExcludeFromUpdate()`. +4. Oferecer atalhos intencionais: + `ReadOnly()` como `ExcludeFromInsert().ExcludeFromUpdate()`; + `Computed()` como `ReadOnly()` + `Generated/Computed`; + `DatabaseDefaultOnInsert()` como `ExcludeFromInsert()` + generated default. +5. Manter ou adaptar Dommel APIs existentes: + `IsKey()`, `IsIdentity()`, `SetGeneratedOption(...)`. + +## Compatibilidade + +Mudancas futuras devem ser aditivas: + +- nao remover `Ignore()`; +- nao alterar assinatura de `IPropertyMap` sem estrategia binaria; +- preferir interface opcional para metadata nova; +- manter `DommelPropertyMap.SetGeneratedOption(...)` funcionando; +- preservar `IsKey()` historico, mas diagnosticar default identity implicito + quando houver risco; +- nao alterar `Dapper.Query()` ou `QueryMapped*` para considerar insert/update. + +## Diagnostics e analyzers + +Novos diagnostics devem ser conservadores: + +- alertar quando `Ignore()` parece usado para default/read-only em Dommel docs + ou exemplos nao e viavel estaticamente sem contexto de comando; +- detectar combinacoes contraditorias, por exemplo `Ignore().ReadOnly()` se a API + permitir cadeia desse tipo; +- detectar `Identity` sem `Key` se essa combinacao for invalida; +- detectar `Key` gerada e `ExcludeFromInsert(false)` se houver API explicita para + reabilitar insert; +- explicar no `Explain()` as semanticas de leitura e persistencia separadas. + +## Interacao com generated materializers + +Generated materializers devem consumir apenas `Read` e `Ignored`. + +Metadata de `Insert`, `Update`, `Key`, `Identity`, `Computed` e +`DefaultOnInsert` nao deve alterar o delegate `IDataRecord -> entity`, exceto +quando tambem afetar `Read`. + +## Plano da Etapa 8 + +1. Modelo de metadata: + criar contrato aditivo e defaults compativeis. +2. APIs publicas: + adicionar fluent methods pequenas e documentadas. +3. Dommel integration: + traduzir metadata para `ColumnPropertyInfo` e key/property resolvers. +4. Historical regression suite: + cobrir #94, #122, #123, #130, #114, #126, #133. +5. Diagnostics/analyzers: + expor e validar combinacoes contraditorias. +6. Documentacao: + atualizar README e XML docs com exemplos de leitura vs escrita. +7. Hardening: + validar cache, profiles, generated materializers, Dommel SQL real e + compatibilidade binaria. diff --git a/.sdd/etapa-8/DECISIONS.md b/.sdd/etapa-8/DECISIONS.md new file mode 100644 index 0000000..207fbdc --- /dev/null +++ b/.sdd/etapa-8/DECISIONS.md @@ -0,0 +1,186 @@ +# Etapa 8 - Architectural Decisions + +## ADR-1 - Read semantics vs write semantics + +### Contexto + +Historicamente, `Ignore()` foi usado para excluir propriedades de mapping. As +issues #94, #123 e #130 mostram que usuarios tambem precisam excluir +propriedades de escrita sem perder materializacao de leitura. + +### Decisao + +Separar conceitualmente `Read`, `Insert` e `Update`. Leitura pertence aos type +maps/materializers; escrita pertence aos consumidores de persistencia, hoje +principalmente Dommel. + +### Alternativas consideradas + +- Manter apenas `Ignore()`. +- Usar apenas `DatabaseGeneratedOption`. +- Criar APIs Dommel-only sem metadata no core. + +### Consequencias + +O core pode descrever metadata, mas nao gera SQL. Dommel traduz metadata para +insert/update. + +## ADR-2 - `Ignore()` vs read-only + +### Contexto + +`Ignore()` impede materializacao. Read-only precisa continuar lendo a coluna, +mas omitir escrita. + +### Decisao + +`Ignore()` continua significando `Read=no`, `Insert=no`, `Update=no`. +Read-only sera uma semantica diferente: `Read=yes`, `Insert=no`, `Update=no`. + +### Alternativas consideradas + +- Alterar `Ignore()` para significar apenas "ignore on write". +- Fazer `Ignore()` depender do pacote consumidor. + +### Consequencias + +Preserva compatibilidade e evita regressao de #133. Usuarios terao API separada +para read-only em prompts futuros. + +## ADR-3 - Localizacao da metadata de persistencia + +### Contexto + +Dommel e o consumidor que gera CRUD, mas a decisao de uma propriedade ser +read-only/default/computed e metadata do mapping. + +### Decisao + +Colocar a metadata conceitual no core por contrato aditivo/opcional, consumido +pelo Dommel. O core nao executa CRUD. + +### Alternativas consideradas + +- Metadata exclusivamente no Dommel. +- Metadata apenas externa em resolvers customizados. +- Expandir `IPropertyMap` diretamente. + +### Consequencias + +Evita duplicacao e permite diagnostics/analyzers. A implementacao deve preservar +compatibilidade binaria, preferindo interface opcional. + +## ADR-4 - Computed vs generated/default + +### Contexto + +Dommel usa `IsGenerated` como filtro operacional, mas generated e uma categoria +ampla. Computed e default-on-insert possuem escrita diferente. + +### Decisao + +`Computed` representa `Read=yes`, `Insert=no`, `Update=no`. +`DatabaseDefaultOnInsert` representa `Read=yes`, `Insert=no` e `Update=yes` por +default, com composicao possivel para tambem excluir update. + +### Alternativas consideradas + +- Tratar todo `Generated` como `Insert=no`, `Update=no`. +- Mapear tudo para `DatabaseGeneratedOption.Computed`. + +### Consequencias + +API futura deve expor intencao mais precisa que apenas `Generated`. + +## ADR-5 - Key vs identity + +### Contexto + +#122 mostrou que key nao identity foi omitida do insert porque key e identity +foram acopladas. + +### Decisao + +`Key` e `Identity` sao dimensoes independentes. Key nao identity entra em +`INSERT`; identity key nao entra em `INSERT`. Keys nao entram no `SET` de +`UPDATE`, mas participam do `WHERE`. + +### Alternativas consideradas + +- Manter `IsKey()` implicando identity sempre. +- Obrigar toda key nao identity a chamar `SetGeneratedOption(None)`. + +### Consequencias + +Preserva o comportamento atual quando necessario, mas a arquitetura futura deve +tornar a intencao explicita e testavel. + +## ADR-6 - Backward compatibility + +### Contexto + +FluentMap e biblioteca publica. `IPropertyMap`, `PropertyMap`, +`DommelPropertyMap` e `FluentMapper.Initialize` sao superficie sensivel. + +### Decisao + +Evolucao deve ser aditiva. Nao remover APIs, nao mudar significado de +`Ignore()`, nao introduzir breaking change sem versao major e justificativa. + +### Alternativas consideradas + +- Redesenhar `IPropertyMap` diretamente. +- Trocar `GeneratedOption` por enum propria removendo API antiga. + +### Consequencias + +Prompts futuros devem preferir interfaces opcionais, defaults compativeis e +adapters internos. + +## ADR-7 - Interacao com Dommel + +### Contexto + +Dommel 3.5.3 constroi `Insert`/`Update` filtrando `ColumnPropertyInfo.IsGenerated`. +O pacote FluentMap.Dommel ja instala resolvers customizados em `ForDommel()`. + +### Decisao + +Dommel continua sendo o unico responsavel por SQL CRUD. FluentMap.Dommel deve +traduzir metadata de persistencia para os contratos publicos do Dommel, +especialmente `ColumnPropertyInfo` e resolvers de key/property. + +### Alternativas consideradas + +- Gerar SQL no core. +- Criar SQL builder proprio para substituir Dommel. +- Duplicar todo o modelo de Dommel no core. + +### Consequencias + +Nao adicionar CRUD ao core. Regression tests de insert/update pertencem ao +projeto Dommel integration. + +## ADR-8 - Interacao com generated materializers + +### Contexto + +Generated materializers da etapa 7 sao otimizacao de leitura +`IDataRecord -> entity`. Eles ja conhecem ignored columns. + +### Decisao + +Generated materializers devem observar apenas semantica de leitura. Metadata de +insert/update/key/identity/computed nao altera materializacao, salvo se tambem +alterar `Read`. + +### Alternativas consideradas + +- Incluir metadata de persistencia nos descriptors de generated materializer. +- Fazer generated materializers validarem toda metadata de persistencia. + +### Consequencias + +Evita acoplamento entre leitura e escrita. O generator/analyzer pode reconhecer +a nova API para diagnostics, mas nao deve mudar delegates de leitura por causa +de insert/update. diff --git a/.sdd/etapa-8/STATUS.md b/.sdd/etapa-8/STATUS.md new file mode 100644 index 0000000..d66b2c0 --- /dev/null +++ b/.sdd/etapa-8/STATUS.md @@ -0,0 +1,105 @@ +# Etapa 8 Status + +## Objetivo + +Definir a arquitetura e a especificacao inicial de semantica de persistencia de +propriedades, separando materializacao/leitura de insert/update, sem implementar +features produtivas significativas. + +## Concluido + +- Executado `git status` antes das alteracoes. +- Lido `README.md`. +- Examinada `Dapper.FluentMap.sln`. +- Examinados projetos core, Dommel, analyzers, generators, testes, + materializacao runtime, generated materialization e profiles. +- Lidos `.sdd/etapa-7/FINAL-REPORT.md` e `.sdd/etapa-7/STATUS.md`. +- Confirmado que `.sdd/etapa-8/` nao existia e criada a pasta. +- Lidas issues historicas #94, #122, #123, #130, #114, #126 e #133. +- Lidos PRs relacionados #129 e #131. +- Investigado Dommel 3.5.3 efetivo e seu uso de `ColumnPropertyInfo.IsGenerated` + em `Insert` e `Update`. +- Criado `.sdd/etapa-8/01-historical-issues.md`. +- Criado `.sdd/etapa-8/02-persistence-semantics-spec.md`. +- Criado `.sdd/etapa-8/DECISIONS.md`. +- Executado `dotnet restore ./Dapper.FluentMap.sln`: sucesso. +- Executado `dotnet build ./Dapper.FluentMap.sln --configuration Release --no-restore`: sucesso, 0 warnings, 0 errors. +- Executado `dotnet test ./Dapper.FluentMap.sln --configuration Release --no-build`: sucesso, 254 testes aprovados. + +## Em andamento + +Nenhum apos o commit local deste prompt. + +## Proximos passos + +1. Criar modelo de metadata aditivo no core. +2. Definir e implementar APIs publicas pequenas para semantica de escrita. +3. Adaptar FluentMap.Dommel para consumir a metadata sem gerar SQL no core. +4. Criar suite de regressao historica para #94, #122, #123, #130, #114, #126 e + #133. +5. Atualizar diagnostics/analyzers. +6. Atualizar README e XML docs. +7. Fazer hardening de cache, profiles, generated materializers e Dommel SQL real. + +## Decisoes relevantes + +- `Read`, `Insert` e `Update` sao dimensoes independentes. +- `Ignore()` continua significando `Read=no`, `Insert=no`, `Update=no`. +- Read-only significa `Read=yes`, `Insert=no`, `Update=no`. +- Metadata de persistencia deve existir no core como contrato aditivo/opcional, + mas CRUD continua fora do core. +- `Computed` e `DatabaseDefaultOnInsert` sao semanticas diferentes. +- `Key` nao implica `Identity`. +- Dommel traduz metadata para `ColumnPropertyInfo` e seus resolvers. +- Generated materializers observam apenas semantica de leitura. + +## Issues historicas + +- #94 ReadOnly Fields: resolver por nova arquitetura. +- #122 Insert issue when key column is not identity: parcialmente corrigida, + manter regressao e separar key/identity. +- #123 Computed property used in insert/update: provavel correcao via resolvers + atuais, ainda requer regressao de SQL real. +- #130 Default value do banco vs `Ignore()`: resolver por nova arquitetura. +- #114 conflito entre property e membros do tipo: ja resolvido, preservar. +- #126 nested properties com mesmo terminal: ja resolvido no core/generated, + preservar. +- #133 `Ignore()` causando `NotImplementedException`: ja resolvido para bug + original, preservar. + +## Riscos conhecidos + +- Compatibilidade binaria se `IPropertyMap` for alterada diretamente. +- `IsKey()` historico ainda implica identity por default no DommelPropertyMap + quando `GeneratedOption` nao e especificado. +- Dommel cacheia resolvers/properties; mudancas de metadata devem considerar + inicializacao global e invalidacao. +- Profiles sao leitura query-scoped e nao devem contaminar metadata global de + escrita sem decisao especifica. +- Nested paths usam `MemberPath`, mas Dommel trabalha com propriedades flat. +- `Generated` e amplo demais para representar sozinho default, computed e + identity. + +## Arquivos importantes + +- `.sdd/etapa-8/01-historical-issues.md` +- `.sdd/etapa-8/02-persistence-semantics-spec.md` +- `.sdd/etapa-8/DECISIONS.md` +- `.sdd/etapa-8/STATUS.md` +- `src/Dapper.FluentMap/Mapping/PropertyMap.cs` +- `src/Dapper.FluentMap/Mapping/EntityMap.cs` +- `src/Dapper.FluentMap/Mapping/MemberPath.cs` +- `src/Dapper.FluentMap/Compatibility/DapperFluentPropertyTypeMap.cs` +- `src/Dapper.FluentMap/Compatibility/DapperIgnoredMemberMap.cs` +- `src/Dapper.FluentMap/Materialization/NestedMaterializationPlan.cs` +- `src/Dapper.FluentMap/Materialization/GeneratedMaterializerColumn.cs` +- `src/Dapper.FluentMap.Dommel/Mapping/DommelPropertyMap.cs` +- `src/Dapper.FluentMap.Dommel/Resolvers/DommelPropertyResolver.cs` +- `src/Dapper.FluentMap.Dommel/Resolvers/DommelKeyPropertyResolver.cs` +- `src/Dapper.FluentMap.Dommel/Resolvers/DommelColumnNameResolver.cs` +- `src/Dapper.FluentMap.Generators/MappingRegistrationGenerator.cs` +- `src/Dapper.FluentMap.Analyzers/FluentMapConfigurationAnalyzer.cs` + +## Ultimo prompt executado + +Ultimo prompt executado: 8.1 From 12a6d9817a5ec638bdb03965059c4eb71121944e Mon Sep 17 00:00:00 2001 From: Rodrigo de Oliveira Date: Tue, 28 Jul 2026 11:32:39 -0300 Subject: [PATCH 09/49] feat(mapping): add property persistence metadata --- .sdd/etapa-8/02-persistence-semantics-spec.md | 88 ++++- .../etapa-8/03-persistence-metadata-design.md | 313 +++++++++++++++ .sdd/etapa-8/DECISIONS.md | 78 ++++ .sdd/etapa-8/STATUS.md | 75 +++- README.md | 34 ++ .../Mapping/DommelPropertyMap.cs | 46 +++ .../Resolvers/DommelKeyPropertyResolver.cs | 2 +- .../Resolvers/DommelPropertyResolver.cs | 22 +- .../Diagnostics/MemberMappingExplanation.cs | 10 +- src/Dapper.FluentMap/Mapping/PropertyMap.cs | 108 ++++- .../Mapping/PropertyPersistenceMetadata.cs | 319 +++++++++++++++ src/Dapper.FluentMap/MappingRegistry.cs | 6 +- .../ManualMappingTests.cs | 116 ++++++ .../PropertyPersistenceMetadataTests.cs | 372 ++++++++++++++++++ 14 files changed, 1565 insertions(+), 24 deletions(-) create mode 100644 .sdd/etapa-8/03-persistence-metadata-design.md create mode 100644 src/Dapper.FluentMap/Mapping/PropertyPersistenceMetadata.cs create mode 100644 test/Dapper.FluentMap.Tests/PropertyPersistenceMetadataTests.cs diff --git a/.sdd/etapa-8/02-persistence-semantics-spec.md b/.sdd/etapa-8/02-persistence-semantics-spec.md index ef9910f..b0f88f3 100644 --- a/.sdd/etapa-8/02-persistence-semantics-spec.md +++ b/.sdd/etapa-8/02-persistence-semantics-spec.md @@ -1,6 +1,6 @@ # Etapa 8 - Persistence Semantics Specification -Status: especificacao inicial, sem implementacao produtiva. +Status: refinada e aplicada no Prompt 8.2. ## Objetivo @@ -299,11 +299,89 @@ Racional: - o core nao deve gerar SQL, mas pode descrever metadata; - Dommel deve continuar responsavel por traduzir metadata para comandos CRUD. -## Forma recomendada para API futura +## Modelo efetivamente escolhido no Prompt 8.2 -Nao implementar neste prompt. Direcao preferida para prompts seguintes: +O core passa a expor um modelo aditivo: -1. Introduzir metadata interna/aditiva de persistencia com defaults compativeis: +- `PropertyPersistenceMetadata`; +- `IPropertyMapWithPersistenceMetadata`; +- `PropertyMapBase.Persistence`. + +`IPropertyMap` nao foi alterada para preservar compatibilidade binaria. Maps que +nao implementam a interface opcional continuam sendo interpretados por defaults: + +```text +Ignored=true => PropertyPersistenceMetadata.Ignored +Ignored=false => PropertyPersistenceMetadata.Default +``` + +Defaults efetivos: + +```text +Read/Materialization = yes +Insert = yes +Update = yes +Ignore = no +Key = no +Identity = no +Generated = no +Computed = no +DefaultOnInsert = no +``` + +`Ignore()` preserva a semantica historica: + +```text +Ignore +=> Read/Materialization = no +=> Insert = no +=> Update = no +=> Key/Identity/etc. = no +``` + +APIs publicas adicionadas ao `PropertyMapBase`: + +```csharp +ExcludeFromInsert(); +ExcludeFromUpdate(); +ReadOnly(); +Computed(); +DatabaseDefaultOnInsert(); +``` + +`ReadOnly()` e um atalho para excluir insert e update sem afetar leitura. +`Computed()` e `ReadOnly()` mais metadata `Generated` e `Computed`. +`DatabaseDefaultOnInsert()` exclui insert, preserva update por default e marca +`Generated` + `DefaultOnInsert`. + +Dommel conecta suas APIs existentes ao mesmo modelo: + +```csharp +IsKey(); +IsIdentity(); +SetGeneratedOption(DatabaseGeneratedOption option); +``` + +No core, key nao implica identity. Na ponte Dommel, o resolver ainda preserva o +comportamento historico de `IsKey()` sem `SetGeneratedOption(None)` como identity +operacional para `ColumnPropertyInfo`, porque mudar isso agora alteraria SQL +gerado por consumidores existentes. A metadata de core, entretanto, diferencia: + +```text +IsKey() => Key=yes, Identity=no, Insert=yes, Update=no +IsIdentity() => Key=yes, Identity=yes, Generated=yes, Insert=no, Update=no +``` + +`ExcludeFromInsert()` isolado e `DatabaseDefaultOnInsert()` com update habilitado +ficam representados corretamente na metadata, mas nao sao traduzidos para +`ColumnPropertyInfo.IsGenerated` quando essa traducao omitiria tambem o update. +Essa traducao operacional pertence a prompt posterior de Dommel/CRUD. + +## Forma recomendada para API publica + +Implementada parcialmente neste prompt, mantendo a direcao: + +1. Introduzir metadata aditiva de persistencia com defaults compativeis: `Read=yes`, `Insert=yes`, `Update=yes`, `Key=no`, `Generated=no`. 2. Preservar `Ignore()` como `Read=no`, `Insert=no`, `Update=no`. 3. Oferecer API composavel por operacao: @@ -312,7 +390,7 @@ Nao implementar neste prompt. Direcao preferida para prompts seguintes: `ReadOnly()` como `ExcludeFromInsert().ExcludeFromUpdate()`; `Computed()` como `ReadOnly()` + `Generated/Computed`; `DatabaseDefaultOnInsert()` como `ExcludeFromInsert()` + generated default. -5. Manter ou adaptar Dommel APIs existentes: +5. Manter e adaptar Dommel APIs existentes: `IsKey()`, `IsIdentity()`, `SetGeneratedOption(...)`. ## Compatibilidade diff --git a/.sdd/etapa-8/03-persistence-metadata-design.md b/.sdd/etapa-8/03-persistence-metadata-design.md new file mode 100644 index 0000000..4ed0d04 --- /dev/null +++ b/.sdd/etapa-8/03-persistence-metadata-design.md @@ -0,0 +1,313 @@ +# Etapa 8 - Persistence Metadata Design + +Status: implementado no Prompt 8.2. + +## Objetivo + +Representar, no core, metadata de persistencia separada da metadata de leitura +sem adicionar CRUD ao FluentMap. O modelo deve ser consumivel por diagnostics, +Dommel e extensoes futuras. + +## Classes e interfaces + +### `PropertyPersistenceMetadata` + +Tipo publico imutavel em `Dapper.FluentMap.Mapping`. + +Propriedades: + +- `ParticipatesInMaterialization`; +- `ParticipatesInInsert`; +- `ParticipatesInUpdate`; +- `IgnoredByFluentMap`; +- `IsKey`; +- `IsIdentity`; +- `IsGenerated`; +- `IsComputed`; +- `HasDatabaseDefaultOnInsert`. + +Instancias estaticas: + +- `PropertyPersistenceMetadata.Default`; +- `PropertyPersistenceMetadata.Ignored`. + +O tipo nao expoe setters. As alteracoes durante configuracao fluente criam uma +nova instancia e substituem a referencia mantida pelo `PropertyMapBase`. + +### `IPropertyMapWithPersistenceMetadata` + +Interface publica aditiva: + +```csharp +public interface IPropertyMapWithPersistenceMetadata +{ + PropertyPersistenceMetadata Persistence { get; } +} +``` + +Ela evita alterar `IPropertyMap`, preservando compatibilidade binaria com +implementacoes customizadas. + +### `PropertyMapBase` + +Agora implementa `IPropertyMapWithPersistenceMetadata` e possui: + +```csharp +public PropertyPersistenceMetadata Persistence { get; } +``` + +APIs fluent publicas adicionadas: + +```csharp +ExcludeFromInsert(); +ExcludeFromUpdate(); +ReadOnly(); +Computed(); +DatabaseDefaultOnInsert(); +``` + +APIs protegidas para maps derivados: + +```csharp +UsePersistence(...); +MarkAsKey(); +MarkAsIdentity(); +MarkAsNotGenerated(); +MarkAsComputed(); +``` + +### Diagnostics + +`MemberMappingExplanation` agora expoe: + +```csharp +public PropertyPersistenceMetadata Persistence { get; } +``` + +`FluentMapper.Explain()` e `Explain()` passam a carregar a +metadata efetiva para mappings explicitos, herdados, convencoes e fallback +Dapper. + +## Defaults + +Propriedade mapeada normal: + +```text +Materialization = yes +Insert = yes +Update = yes +Ignored = no +Key = no +Identity = no +Generated = no +Computed = no +DefaultOnInsert = no +``` + +Fallback Dapper e maps customizados sem a interface opcional usam o mesmo +default quando `Ignored=false`. + +## Invariants + +### Ignore + +`Ignore()` continua sendo historico e total: + +```text +Ignore +=> Materialization=no +=> Insert=no +=> Update=no +=> Key=no +=> Identity=no +=> Generated=no +=> Computed=no +=> DefaultOnInsert=no +``` + +APIs de escrita chamadas depois de `Ignore()` falham com +`FluentMapConfigurationException`. + +### Read-only + +```text +ReadOnly() +=> Materialization=yes +=> Insert=no +=> Update=no +=> Ignored=no +``` + +Read-only nao implica generated. + +### Exclude por operacao + +```text +ExcludeFromInsert() => Insert=no, Update preservado +ExcludeFromUpdate() => Update=no, Insert preservado +``` + +Chamadas repetidas e combinacoes equivalentes sao idempotentes. + +### Computed + +```text +Computed() +=> Materialization=yes +=> Insert=no +=> Update=no +=> Generated=yes +=> Computed=yes +=> DefaultOnInsert=no +``` + +Computed nao pode ser combinado com `DatabaseDefaultOnInsert()` nem com key. + +### Database default on insert + +```text +DatabaseDefaultOnInsert() +=> Materialization=yes +=> Insert=no +=> Update=yes +=> Generated=yes +=> DefaultOnInsert=yes +``` + +Pode ser combinado com `ExcludeFromUpdate()` quando o valor tambem deve ser +read-only depois do insert. + +### Key + +```text +IsKey() +=> Key=yes +=> Identity=no +=> Insert=yes +=> Update=no +``` + +Key nao identity continua insertable no modelo de metadata. + +### Identity + +```text +IsIdentity() +=> Key=yes +=> Identity=yes +=> Generated=yes +=> Insert=no +=> Update=no +``` + +Identity e tratado como key gerada pelo banco. + +## Combinacoes validas + +- default mapping; +- `ReadOnly()`; +- `ExcludeFromInsert()`; +- `ExcludeFromUpdate()`; +- `ExcludeFromInsert().ExcludeFromUpdate()`; +- `DatabaseDefaultOnInsert()`; +- `DatabaseDefaultOnInsert().ExcludeFromUpdate()`; +- `Computed()`; +- `IsKey()`; +- `IsKey().SetGeneratedOption(DatabaseGeneratedOption.None)`; +- `IsIdentity().IsKey()` e `IsKey().IsIdentity()`; +- mappings herdados com qualquer metadata valida; +- profiles com metadata propria. + +## Combinacoes invalidas + +- `Ignore().ReadOnly()`; +- `Ignore().ExcludeFromInsert()`; +- `Ignore().ExcludeFromUpdate()`; +- `Ignore().Computed()`; +- `Ignore().DatabaseDefaultOnInsert()`; +- `Computed().DatabaseDefaultOnInsert()`; +- `Computed().IsKey()`; +- `Computed().IsIdentity()`; +- `DatabaseDefaultOnInsert().IsIdentity()`. + +`Ignore()` chamado no fim da cadeia continua permitido e domina a metadata, +preservando o significado historico de "nao mapear". + +## Backward compatibility + +- `IPropertyMap` nao foi alterada. +- `Ignore()` nao mudou de significado. +- `PropertyMap` e `DommelPropertyMap` continuam existindo. +- APIs Dommel existentes foram preservadas. +- `IsKey()` no modelo de metadata nao implica identity, mas o resolver Dommel + ainda preserva o comportamento operacional historico quando `GeneratedOption` + nao e especificado. +- `SetGeneratedOption(DatabaseGeneratedOption.None)` continua sendo a forma + compativel de declarar key nao gerada no Dommel atual. +- `Dapper.Query()`, `QueryMapped*` e generated materializers continuam + consumindo apenas semantica de leitura/ignore. + +## Relacao com mappings existentes + +Mappings explicitos, convencoes, naming policies, inherited maps e profiles +recebem metadata default automaticamente porque todos usam `PropertyMapBase`. + +Implementacoes customizadas que implementam apenas `IPropertyMap` continuam +validas. Internamente elas sao adaptadas para: + +```text +Ignored=true => PropertyPersistenceMetadata.Ignored +Ignored=false => PropertyPersistenceMetadata.Default +``` + +## Relacao com Dommel + +`DommelPropertyMap` escreve na metadata ao chamar: + +- `IsKey()`; +- `IsIdentity()`; +- `SetGeneratedOption(...)`. + +O resolver Dommel tambem consegue consumir `IPropertyMapWithPersistenceMetadata` +em maps do core ou customizados quando o estado pode ser representado pelo +contrato atual do Dommel. + +Para `DommelPropertyMap`, o resolver calcula uma `EffectiveGeneratedOption`: + +- `GeneratedOption` explicito vence; +- identity vira `DatabaseGeneratedOption.Identity`; +- metadata sem insert/update vira `DatabaseGeneratedOption.Computed`; +- key sem opcao explicita preserva o legado como identity operacional; +- demais propriedades usam `None`. + +Nao ha geracao de SQL no core. Separacao fina `Insert=no`, `Update=yes` ainda +nao cabe no contrato atual `ColumnPropertyInfo.IsGenerated` sem alterar +comportamento de update, entao fica como metadata para prompt posterior. + +## Relacao com source generator + +Generated materializers continuam observando apenas: + +- `ParticipatesInMaterialization`; +- `IgnoredByFluentMap` via o flag historico `Ignored`. + +Metadata de insert/update/key/identity/computed/default nao altera delegates +`IDataRecord -> entity`. + +Prompts futuros podem atualizar analyzers/generator para reconhecer chamadas da +DSL e emitir diagnostics, mas nao devem alterar materializacao por causa de +semantica de escrita. + +## Relacao com diagnostics + +`Explain()` ja expoe a metadata em `MemberMappingExplanation.Persistence`. +Isso permite: + +- inspecionar defaults; +- distinguir `Ignore()` de `ReadOnly()`; +- ver metadata herdada; +- ver metadata especifica de profile; +- sustentar diagnostics futuros sem depender de SQL/CRUD. + +Diagnostics mais fortes para combinacoes contraditorias podem evoluir sobre o +mesmo modelo. diff --git a/.sdd/etapa-8/DECISIONS.md b/.sdd/etapa-8/DECISIONS.md index 207fbdc..450e58a 100644 --- a/.sdd/etapa-8/DECISIONS.md +++ b/.sdd/etapa-8/DECISIONS.md @@ -184,3 +184,81 @@ alterar `Read`. Evita acoplamento entre leitura e escrita. O generator/analyzer pode reconhecer a nova API para diagnostics, mas nao deve mudar delegates de leitura por causa de insert/update. + +## ADR-9 - Metadata imutavel exposta por interface aditiva + +### Contexto + +`IPropertyMap` e superficie publica sensivel. Adicionar propriedades diretamente +seria breaking para implementacoes customizadas. + +### Decisao + +Criar `PropertyPersistenceMetadata` como objeto imutavel e +`IPropertyMapWithPersistenceMetadata` como interface opcional. `PropertyMapBase` +implementa a interface. + +### Alternativas consideradas + +- Adicionar propriedades a `IPropertyMap`. +- Espalhar bools independentes em `PropertyMapBase`. +- Usar somente enum flags. + +### Consequencias + +Compatibilidade binaria preservada. O modelo fica coeso e inspecionavel por +diagnostics e extensoes. + +## ADR-10 - API publica minima no core + +### Contexto + +A Etapa 8 precisa expressar escrita sem transformar o core em CRUD. + +### Decisao + +Adicionar somente: + +- `ExcludeFromInsert()`; +- `ExcludeFromUpdate()`; +- `ReadOnly()`; +- `Computed()`; +- `DatabaseDefaultOnInsert()`. + +### Consequencias + +O core descreve intencao e participacao, mas nao gera SQL. APIs de Dommel +existentes continuam responsaveis por key/identity. + +## ADR-11 - Ponte Dommel conservadora + +### Contexto + +Dommel 3.5.3 possui `ColumnPropertyInfo.IsGenerated`, mas nao separa insert e +update no contrato consumido hoje. + +### Decisao + +`DommelPropertyMap` grava metadata. Os resolvers traduzem apenas estados que o +contrato atual consegue representar sem perder semantica critica. Key sem +`GeneratedOption` preserva o legado operacional como identity. + +### Consequencias + +Metadata de `ExcludeFromInsert()` isolado fica disponivel para futuro consumo, +mas nao e forcada como `IsGenerated` quando isso tambem removeria update. + +## ADR-12 - Explain expoe persistence metadata + +### Contexto + +Diagnostics futuros precisam diferenciar leitura, ignore e escrita. + +### Decisao + +Adicionar `MemberMappingExplanation.Persistence`. + +### Consequencias + +`FluentMapper.Explain()` passa a expor a metadata efetiva sem acoplar +diagnostics a SQL ou a Dommel. diff --git a/.sdd/etapa-8/STATUS.md b/.sdd/etapa-8/STATUS.md index d66b2c0..0e7edb3 100644 --- a/.sdd/etapa-8/STATUS.md +++ b/.sdd/etapa-8/STATUS.md @@ -2,9 +2,9 @@ ## Objetivo -Definir a arquitetura e a especificacao inicial de semantica de persistencia de -propriedades, separando materializacao/leitura de insert/update, sem implementar -features produtivas significativas. +Definir e implementar o modelo inicial de metadata de persistencia de +propriedades, separando materializacao/leitura de insert/update, sem adicionar +execucao de CRUD ao core. ## Concluido @@ -25,21 +25,64 @@ features produtivas significativas. - Executado `dotnet restore ./Dapper.FluentMap.sln`: sucesso. - Executado `dotnet build ./Dapper.FluentMap.sln --configuration Release --no-restore`: sucesso, 0 warnings, 0 errors. - Executado `dotnet test ./Dapper.FluentMap.sln --configuration Release --no-build`: sucesso, 254 testes aprovados. +- Confirmado que o Prompt 8.1 estava aplicado nos documentos da Etapa 8 e no + estado do README/codigo ja existente. +- Nao encontrada divergencia entre a documentacao da Etapa 8.1 e a + implementacao atual que exigisse registro corretivo separado. +- Implementado `PropertyPersistenceMetadata`. +- Implementado `IPropertyMapWithPersistenceMetadata`. +- Adicionada metadata `Persistence` em `PropertyMapBase`. +- Adicionadas APIs fluent: + - `ExcludeFromInsert()`; + - `ExcludeFromUpdate()`; + - `ReadOnly()`; + - `Computed()`; + - `DatabaseDefaultOnInsert()`. +- Preservado `Ignore()` como `Read=no`, `Insert=no`, `Update=no`. +- Conectado `DommelPropertyMap.IsKey()`, `IsIdentity()` e + `SetGeneratedOption(...)` a metadata de persistencia. +- Adicionada ponte conservadora `EffectiveGeneratedOption` para os resolvers + Dommel. +- Adicionada metadata em `MemberMappingExplanation.Persistence` para + `Explain()` e `Explain()`. +- Criado `.sdd/etapa-8/03-persistence-metadata-design.md`. +- Atualizado `.sdd/etapa-8/02-persistence-semantics-spec.md` com o modelo + efetivo. +- Atualizado `README.md` com a API publica nova. +- Adicionados testes de metadata no core e em Dommel. +- Executado `dotnet build .\Dapper.FluentMap.sln --configuration Release`: + sucesso, 0 warnings, 0 errors. +- Executado `dotnet test .\test\Dapper.FluentMap.Tests\Dapper.FluentMap.Tests.csproj --configuration Release --no-build`: + sucesso, 228 testes aprovados. +- Executado `dotnet test .\test\Dapper.FluentMap.Dommel.Tests\Dapper.FluentMap.Dommel.Tests.csproj --configuration Release --no-build`: + sucesso, 13 testes aprovados. +- Executado `dotnet restore .\Dapper.FluentMap.sln`: sucesso. +- Executado `dotnet build .\Dapper.FluentMap.sln --configuration Release --no-restore`: + sucesso, 0 warnings, 0 errors. +- Executado novamente `dotnet build .\Dapper.FluentMap.sln --configuration Release --no-restore`: + sucesso, 0 warnings, 0 errors. +- Executado novamente `dotnet test .\Dapper.FluentMap.sln --configuration Release --no-build`: + sucesso, 274 testes aprovados. +- Executado `dotnet pack .\src\Dapper.FluentMap\Dapper.FluentMap.csproj --configuration Release --no-build --output .\artifacts\packages`: + sucesso; warning legado `NU5125` sobre `PackageLicenseUrl`/`licenseUrl`. +- Inspecionado `artifacts/packages/Dapper.FluentMap.2.0.0.nupkg`: + contem `lib/netstandard2.0/Dapper.FluentMap.dll`, + `lib/netstandard2.0/Dapper.FluentMap.xml`, nuspec e metadados NuGet; dependencia + `Dapper` 2.1.79 preservada. ## Em andamento -Nenhum apos o commit local deste prompt. +Nenhum apos a validacao final deste prompt. ## Proximos passos -1. Criar modelo de metadata aditivo no core. -2. Definir e implementar APIs publicas pequenas para semantica de escrita. -3. Adaptar FluentMap.Dommel para consumir a metadata sem gerar SQL no core. -4. Criar suite de regressao historica para #94, #122, #123, #130, #114, #126 e +1. Adaptar FluentMap.Dommel para consumo operacional completo de metadata + `Insert`/`Update` quando houver contrato seguro para nao confundir update com + generated. +2. Criar suite de regressao historica para #94, #122, #123, #130, #114, #126 e #133. -5. Atualizar diagnostics/analyzers. -6. Atualizar README e XML docs. -7. Fazer hardening de cache, profiles, generated materializers e Dommel SQL real. +3. Atualizar analyzers/source generator para reconhecer a nova DSL. +4. Fazer hardening de cache, profiles, generated materializers e Dommel SQL real. ## Decisoes relevantes @@ -52,6 +95,8 @@ Nenhum apos o commit local deste prompt. - `Key` nao implica `Identity`. - Dommel traduz metadata para `ColumnPropertyInfo` e seus resolvers. - Generated materializers observam apenas semantica de leitura. +- `IPropertyMap` nao foi alterada; metadata nova fica em interface opcional. +- `Explain()` ja expoe metadata de persistencia. ## Issues historicas @@ -79,6 +124,11 @@ Nenhum apos o commit local deste prompt. - Nested paths usam `MemberPath`, mas Dommel trabalha com propriedades flat. - `Generated` e amplo demais para representar sozinho default, computed e identity. +- Dommel ainda tem uma ponte de compatibilidade: `IsKey()` sem + `SetGeneratedOption(None)` continua identity operacional nos resolvers, embora + a metadata de core diferencie key de identity. +- `ExcludeFromInsert()` isolado e `DatabaseDefaultOnInsert()` com update ativo + ainda nao podem ser traduzidos fielmente para `ColumnPropertyInfo.IsGenerated`. ## Arquivos importantes @@ -87,6 +137,7 @@ Nenhum apos o commit local deste prompt. - `.sdd/etapa-8/DECISIONS.md` - `.sdd/etapa-8/STATUS.md` - `src/Dapper.FluentMap/Mapping/PropertyMap.cs` +- `src/Dapper.FluentMap/Mapping/PropertyPersistenceMetadata.cs` - `src/Dapper.FluentMap/Mapping/EntityMap.cs` - `src/Dapper.FluentMap/Mapping/MemberPath.cs` - `src/Dapper.FluentMap/Compatibility/DapperFluentPropertyTypeMap.cs` @@ -102,4 +153,4 @@ Nenhum apos o commit local deste prompt. ## Ultimo prompt executado -Ultimo prompt executado: 8.1 +Ultimo prompt executado: 8.2 diff --git a/README.md b/README.md index 2366ee5..3df9b4a 100644 --- a/README.md +++ b/README.md @@ -93,6 +93,23 @@ public sealed class ProductMap : EntityMap Explicit mappings take precedence over convention mappings. Unmapped members fall back to Dapper's normal behavior. +Persistence metadata can describe write participation without changing read materialization: + +```csharp +Map(product => product.CreatedAt) + .ToColumn("created_at") + .DatabaseDefaultOnInsert(); + +Map(product => product.UpdatedAt) + .ToColumn("updated_at") + .ReadOnly(); + +Map(product => product.Total) + .Computed(); +``` + +`Ignore()` keeps its historical meaning: the property does not participate in FluentMap materialization or generated persistence metadata. `ReadOnly()` still allows materialization, but excludes the property from INSERT and UPDATE metadata. + Inherited explicit mappings can be included when the derived entity should reuse a base entity map: ```csharp @@ -516,6 +533,23 @@ public sealed class ProductMap : EntityMap Mapeamentos explícitos têm precedência sobre convenções. Membros não mapeados usam o comportamento normal do Dapper. +Metadata de persistência pode descrever participação em escrita sem alterar a materialização de leitura: + +```csharp +Map(product => product.CreatedAt) + .ToColumn("created_at") + .DatabaseDefaultOnInsert(); + +Map(product => product.UpdatedAt) + .ToColumn("updated_at") + .ReadOnly(); + +Map(product => product.Total) + .Computed(); +``` + +`Ignore()` mantém seu significado histórico: a propriedade não participa da materialização do FluentMap nem da metadata de persistência gerada. `ReadOnly()` ainda permite materialização, mas exclui a propriedade da metadata de INSERT e UPDATE. + Mapeamentos explícitos herdados podem ser incluídos quando a entidade derivada deve reutilizar um map da entidade base: ```csharp diff --git a/src/Dapper.FluentMap.Dommel/Mapping/DommelPropertyMap.cs b/src/Dapper.FluentMap.Dommel/Mapping/DommelPropertyMap.cs index 9e7e1b3..4e17c90 100644 --- a/src/Dapper.FluentMap.Dommel/Mapping/DommelPropertyMap.cs +++ b/src/Dapper.FluentMap.Dommel/Mapping/DommelPropertyMap.cs @@ -33,6 +33,29 @@ public DommelPropertyMap(PropertyInfo info) : base(info) /// public DatabaseGeneratedOption? GeneratedOption { get; set; } + internal DatabaseGeneratedOption EffectiveGeneratedOption + { + get + { + if (GeneratedOption.HasValue) + { + return GeneratedOption.Value; + } + + if (Persistence.IsIdentity) + { + return DatabaseGeneratedOption.Identity; + } + + if (!Persistence.ParticipatesInInsert && !Persistence.ParticipatesInUpdate) + { + return DatabaseGeneratedOption.Computed; + } + + return Key ? DatabaseGeneratedOption.Identity : DatabaseGeneratedOption.None; + } + } + /// /// Specifies the current property as key for the entity. /// @@ -40,6 +63,7 @@ public DommelPropertyMap(PropertyInfo info) : base(info) public DommelPropertyMap IsKey() { Key = true; + MarkAsKey(); return this; } @@ -50,6 +74,8 @@ public DommelPropertyMap IsKey() public DommelPropertyMap IsIdentity() { Identity = true; + Key = true; + MarkAsIdentity(); return this; } @@ -59,6 +85,26 @@ public DommelPropertyMap IsIdentity() public DommelPropertyMap SetGeneratedOption(DatabaseGeneratedOption option) { GeneratedOption = option; + + switch (option) + { + case DatabaseGeneratedOption.None: + Identity = false; + MarkAsNotGenerated(); + break; + case DatabaseGeneratedOption.Identity: + Identity = true; + Key = true; + MarkAsIdentity(); + break; + case DatabaseGeneratedOption.Computed: + MarkAsComputed(); + break; + default: + MarkAsNotGenerated(); + break; + } + return this; } } diff --git a/src/Dapper.FluentMap.Dommel/Resolvers/DommelKeyPropertyResolver.cs b/src/Dapper.FluentMap.Dommel/Resolvers/DommelKeyPropertyResolver.cs index 54b8fb7..d0063ea 100644 --- a/src/Dapper.FluentMap.Dommel/Resolvers/DommelKeyPropertyResolver.cs +++ b/src/Dapper.FluentMap.Dommel/Resolvers/DommelKeyPropertyResolver.cs @@ -30,7 +30,7 @@ public ColumnPropertyInfo[] ResolveKeyProperties(Type type) var allPropertyMaps = entityMap.PropertyMaps.OfType(); var keyPropertyInfos = allPropertyMaps .Where(e => e.Key) - .Select(x => new ColumnPropertyInfo(x.PropertyInfo, x.GeneratedOption ?? (x.Key ? DatabaseGeneratedOption.Identity : DatabaseGeneratedOption.None))) + .Select(x => new ColumnPropertyInfo(x.PropertyInfo, x.EffectiveGeneratedOption)) .ToArray(); // Now make sure there aren't any missing key properties that weren't explicitly defined in the mapping. diff --git a/src/Dapper.FluentMap.Dommel/Resolvers/DommelPropertyResolver.cs b/src/Dapper.FluentMap.Dommel/Resolvers/DommelPropertyResolver.cs index 3a0696d..5387d47 100644 --- a/src/Dapper.FluentMap.Dommel/Resolvers/DommelPropertyResolver.cs +++ b/src/Dapper.FluentMap.Dommel/Resolvers/DommelPropertyResolver.cs @@ -46,11 +46,14 @@ public override IEnumerable ResolveProperties(Type type) var dommelPropertyMap = propertyMap as DommelPropertyMap; if (dommelPropertyMap != null) { - yield return new ColumnPropertyInfo(property, dommelPropertyMap.GeneratedOption ?? (dommelPropertyMap.Key ? DatabaseGeneratedOption.Identity : DatabaseGeneratedOption.None)); + yield return new ColumnPropertyInfo(property, dommelPropertyMap.EffectiveGeneratedOption); } else { - yield return new ColumnPropertyInfo(property); + var mapWithPersistence = propertyMap as IPropertyMapWithPersistenceMetadata; + yield return mapWithPersistence == null + ? new ColumnPropertyInfo(property) + : new ColumnPropertyInfo(property, ResolveGeneratedOption(mapWithPersistence.Persistence)); } } } @@ -63,5 +66,20 @@ public override IEnumerable ResolveProperties(Type type) } } } + + private static DatabaseGeneratedOption ResolveGeneratedOption(PropertyPersistenceMetadata persistence) + { + if (persistence.IsIdentity) + { + return DatabaseGeneratedOption.Identity; + } + + if (!persistence.ParticipatesInInsert && !persistence.ParticipatesInUpdate) + { + return DatabaseGeneratedOption.Computed; + } + + return DatabaseGeneratedOption.None; + } } } diff --git a/src/Dapper.FluentMap/Diagnostics/MemberMappingExplanation.cs b/src/Dapper.FluentMap/Diagnostics/MemberMappingExplanation.cs index 3a5d4d6..4482c96 100644 --- a/src/Dapper.FluentMap/Diagnostics/MemberMappingExplanation.cs +++ b/src/Dapper.FluentMap/Diagnostics/MemberMappingExplanation.cs @@ -3,6 +3,7 @@ using System.Collections.ObjectModel; using System.Linq; using System.Reflection; +using Dapper.FluentMap.Mapping; namespace Dapper.FluentMap.Diagnostics { @@ -21,7 +22,8 @@ internal MemberMappingExplanation( Type inheritedFrom, Type conventionType, IEnumerable constructorParameters, - MappingMaterialization materialization) + MappingMaterialization materialization, + PropertyPersistenceMetadata persistence) { if (string.IsNullOrEmpty(memberPath)) { @@ -44,6 +46,7 @@ internal MemberMappingExplanation( ConstructorParameters = new ReadOnlyCollection( (constructorParameters ?? Enumerable.Empty()).ToList()); Materialization = materialization; + Persistence = persistence ?? PropertyPersistenceMetadata.Default; } /// @@ -95,5 +98,10 @@ internal MemberMappingExplanation( /// Gets how this member is materialized. /// public MappingMaterialization Materialization { get; } + + /// + /// Gets the persistence metadata associated with this member mapping. + /// + public PropertyPersistenceMetadata Persistence { get; } } } diff --git a/src/Dapper.FluentMap/Mapping/PropertyMap.cs b/src/Dapper.FluentMap/Mapping/PropertyMap.cs index 522ef1d..56b811e 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 : IPropertyMapWithMemberPath + public abstract class PropertyMapBase : IPropertyMapWithMemberPath, IPropertyMapWithPersistenceMetadata where TPropertyMap : class, IPropertyMap { /// @@ -52,6 +52,7 @@ protected PropertyMapBase(PropertyInfo info) PropertyInfo = info; MemberPath = Dapper.FluentMap.Mapping.MemberPath.ForProperty(info); ColumnName = info.Name; + Persistence = PropertyPersistenceMetadata.Default; } /// @@ -71,6 +72,7 @@ internal PropertyMapBase(PropertyInfo info, string columnName) PropertyInfo = info; MemberPath = Dapper.FluentMap.Mapping.MemberPath.ForProperty(info); ColumnName = columnName; + Persistence = PropertyPersistenceMetadata.Default; } /// @@ -92,6 +94,7 @@ internal PropertyMapBase(PropertyInfo info, string columnName, bool caseSensitiv MemberPath = Dapper.FluentMap.Mapping.MemberPath.ForProperty(info); ColumnName = columnName; CaseSensitive = caseSensitive; + Persistence = PropertyPersistenceMetadata.Default; } /// @@ -114,6 +117,11 @@ internal PropertyMapBase(PropertyInfo info, string columnName, bool caseSensitiv /// public PropertyInfo PropertyInfo { get; } + /// + /// Gets the persistence metadata configured for this property. + /// + public PropertyPersistenceMetadata Persistence { get; private set; } + internal MemberPath MemberPath { get; private set; } MemberPath IPropertyMapWithMemberPath.MemberPath => MemberPath; @@ -148,9 +156,107 @@ public TPropertyMap ToColumn(string columnName, bool caseSensitive = true) public TPropertyMap Ignore() { Ignored = true; + Persistence = PropertyPersistenceMetadata.Ignored; + return this as TPropertyMap; + } + + /// + /// Excludes the current property from generated INSERT operations while preserving read materialization. + /// + /// The current instance of . + public TPropertyMap ExcludeFromInsert() + { + Persistence = Persistence.ExcludeFromInsert(); + return this as TPropertyMap; + } + + /// + /// Excludes the current property from generated UPDATE operations while preserving read materialization. + /// + /// The current instance of . + public TPropertyMap ExcludeFromUpdate() + { + Persistence = Persistence.ExcludeFromUpdate(); + return this as TPropertyMap; + } + + /// + /// Marks the current property as read-only for generated persistence operations. + /// + /// The current instance of . + public TPropertyMap ReadOnly() + { + Persistence = Persistence.ReadOnly(); + return this as TPropertyMap; + } + + /// + /// Marks the current property as computed by the database. + /// + /// The current instance of . + public TPropertyMap Computed() + { + Persistence = Persistence.Computed(); + return this as TPropertyMap; + } + + /// + /// Marks the current property as having a database default value when omitted from INSERT. + /// + /// The current instance of . + public TPropertyMap DatabaseDefaultOnInsert() + { + Persistence = Persistence.DatabaseDefaultOnInsert(); return this as TPropertyMap; } + /// + /// Applies persistence metadata configured by derived mapping types. + /// + /// The persistence metadata to apply. + protected void UsePersistence(PropertyPersistenceMetadata persistence) + { + if (persistence == null) + { + throw new ArgumentNullException(nameof(persistence)); + } + + Persistence = persistence; + Ignored = persistence.IgnoredByFluentMap; + } + + /// + /// Marks the current property as a persistence key. + /// + protected void MarkAsKey() + { + UsePersistence(Persistence.Key()); + } + + /// + /// Marks the current property as an identity generated by the database. + /// + protected void MarkAsIdentity() + { + UsePersistence(Persistence.Identity()); + } + + /// + /// Clears database-generated semantics from the current property. + /// + protected void MarkAsNotGenerated() + { + UsePersistence(Persistence.GeneratedNone()); + } + + /// + /// Marks the current property as computed by the database. + /// + protected void MarkAsComputed() + { + UsePersistence(Persistence.Computed()); + } + #region EditorBrowsableStates /// [EditorBrowsable(EditorBrowsableState.Never)] diff --git a/src/Dapper.FluentMap/Mapping/PropertyPersistenceMetadata.cs b/src/Dapper.FluentMap/Mapping/PropertyPersistenceMetadata.cs new file mode 100644 index 0000000..d3872ac --- /dev/null +++ b/src/Dapper.FluentMap/Mapping/PropertyPersistenceMetadata.cs @@ -0,0 +1,319 @@ +using System; + +namespace Dapper.FluentMap.Mapping +{ + /// + /// Describes how a mapped property participates in materialization and persistence operations. + /// + public sealed class PropertyPersistenceMetadata + { + /// + /// Gets the default persistence metadata for a mapped property. + /// + public static readonly PropertyPersistenceMetadata Default = + new PropertyPersistenceMetadata( + participatesInMaterialization: true, + participatesInInsert: true, + participatesInUpdate: true, + ignored: false, + key: false, + identity: false, + generated: false, + computed: false, + databaseDefaultOnInsert: false); + + /// + /// Gets the persistence metadata for a property ignored by FluentMap. + /// + public static readonly PropertyPersistenceMetadata Ignored = + new PropertyPersistenceMetadata( + participatesInMaterialization: false, + participatesInInsert: false, + participatesInUpdate: false, + ignored: true, + key: false, + identity: false, + generated: false, + computed: false, + databaseDefaultOnInsert: false); + + private PropertyPersistenceMetadata( + bool participatesInMaterialization, + bool participatesInInsert, + bool participatesInUpdate, + bool ignored, + bool key, + bool identity, + bool generated, + bool computed, + bool databaseDefaultOnInsert) + { + if (ignored && (participatesInMaterialization || participatesInInsert || participatesInUpdate)) + { + throw new ArgumentException("Ignored properties cannot participate in materialization, insert or update."); + } + + if (ignored && (key || identity || generated || computed || databaseDefaultOnInsert)) + { + throw new ArgumentException("Ignored properties cannot also be key, identity, generated, computed or database-default properties."); + } + + if (computed && databaseDefaultOnInsert) + { + throw new ArgumentException("A property cannot be both computed and database-default-on-insert."); + } + + if (computed && (participatesInInsert || participatesInUpdate)) + { + throw new ArgumentException("Computed properties cannot participate in insert or update."); + } + + if (computed && !generated) + { + throw new ArgumentException("Computed properties must be generated."); + } + + if (identity && !generated) + { + throw new ArgumentException("Identity properties must be generated."); + } + + if (identity && (participatesInInsert || participatesInUpdate)) + { + throw new ArgumentException("Identity properties cannot participate in insert or update."); + } + + ParticipatesInMaterialization = participatesInMaterialization; + ParticipatesInInsert = participatesInInsert; + ParticipatesInUpdate = participatesInUpdate; + IgnoredByFluentMap = ignored; + IsKey = key; + IsIdentity = identity; + IsGenerated = generated; + IsComputed = computed; + HasDatabaseDefaultOnInsert = databaseDefaultOnInsert; + } + + /// + /// Gets a value indicating whether this property participates in read materialization. + /// + public bool ParticipatesInMaterialization { get; } + + /// + /// Gets a value indicating whether this property participates in generated INSERT commands. + /// + public bool ParticipatesInInsert { get; } + + /// + /// Gets a value indicating whether this property participates in generated UPDATE commands. + /// + public bool ParticipatesInUpdate { get; } + + /// + /// Gets a value indicating whether this property is ignored by FluentMap. + /// + public bool IgnoredByFluentMap { get; } + + /// + /// Gets a value indicating whether this property identifies the row. + /// + public bool IsKey { get; } + + /// + /// Gets a value indicating whether this property is an identity generated by the database. + /// + public bool IsIdentity { get; } + + /// + /// Gets a value indicating whether this property can be generated by the database. + /// + public bool IsGenerated { get; } + + /// + /// Gets a value indicating whether this property is computed by the database. + /// + public bool IsComputed { get; } + + /// + /// Gets a value indicating whether the database supplies a default value when the column is omitted from INSERT. + /// + public bool HasDatabaseDefaultOnInsert { get; } + + internal PropertyPersistenceMetadata ExcludeFromInsert() + { + EnsureNotIgnored(); + + if (IsIdentity) + { + return this; + } + + return With(participatesInInsert: false); + } + + internal PropertyPersistenceMetadata ExcludeFromUpdate() + { + EnsureNotIgnored(); + + return With(participatesInUpdate: false); + } + + internal PropertyPersistenceMetadata ReadOnly() + { + EnsureNotIgnored(); + + return With(participatesInInsert: false, participatesInUpdate: false); + } + + internal PropertyPersistenceMetadata Computed() + { + EnsureNotIgnored(); + EnsureNotKey(nameof(Computed)); + + return With( + participatesInInsert: false, + participatesInUpdate: false, + generated: true, + computed: true, + databaseDefaultOnInsert: false); + } + + internal PropertyPersistenceMetadata DatabaseDefaultOnInsert() + { + EnsureNotIgnored(); + + if (IsComputed) + { + throw new FluentMapConfigurationException("A computed property cannot also be configured with a database default on insert."); + } + + if (IsIdentity) + { + throw new FluentMapConfigurationException("An identity property cannot also be configured with a database default on insert."); + } + + return With( + participatesInInsert: false, + generated: true, + databaseDefaultOnInsert: true); + } + + internal PropertyPersistenceMetadata Key() + { + EnsureNotIgnored(); + + if (IsComputed) + { + throw new FluentMapConfigurationException("A computed property cannot also be configured as a key."); + } + + return With(key: true, participatesInUpdate: false); + } + + internal PropertyPersistenceMetadata Identity() + { + EnsureNotIgnored(); + + if (IsComputed) + { + throw new FluentMapConfigurationException("A computed property cannot also be configured as an identity."); + } + + if (HasDatabaseDefaultOnInsert) + { + throw new FluentMapConfigurationException("A database-default property cannot also be configured as an identity."); + } + + return With( + participatesInInsert: false, + participatesInUpdate: false, + key: true, + identity: true, + generated: true); + } + + internal PropertyPersistenceMetadata GeneratedNone() + { + EnsureNotIgnored(); + + return With( + participatesInInsert: true, + participatesInUpdate: !IsKey, + identity: false, + generated: false, + computed: false, + databaseDefaultOnInsert: false); + } + + private void EnsureNotIgnored() + { + if (IgnoredByFluentMap) + { + throw new FluentMapConfigurationException("Ignored properties cannot be configured with persistence write semantics."); + } + } + + private void EnsureNotKey(string operation) + { + if (IsKey) + { + throw new FluentMapConfigurationException($"A key property cannot also be configured with {operation} persistence semantics."); + } + } + + private PropertyPersistenceMetadata With( + bool? participatesInMaterialization = null, + bool? participatesInInsert = null, + bool? participatesInUpdate = null, + bool? ignored = null, + bool? key = null, + bool? identity = null, + bool? generated = null, + bool? computed = null, + bool? databaseDefaultOnInsert = null) + { + return new PropertyPersistenceMetadata( + participatesInMaterialization ?? ParticipatesInMaterialization, + participatesInInsert ?? ParticipatesInInsert, + participatesInUpdate ?? ParticipatesInUpdate, + ignored ?? IgnoredByFluentMap, + key ?? IsKey, + identity ?? IsIdentity, + generated ?? IsGenerated, + computed ?? IsComputed, + databaseDefaultOnInsert ?? HasDatabaseDefaultOnInsert); + } + } + + /// + /// Exposes persistence metadata for a property map without changing the original contract. + /// + public interface IPropertyMapWithPersistenceMetadata + { + /// + /// Gets the configured persistence metadata for the property. + /// + PropertyPersistenceMetadata Persistence { get; } + } + + internal static class PropertyMapPersistence + { + internal static PropertyPersistenceMetadata GetPersistence(IPropertyMap propertyMap) + { + if (propertyMap == null) + { + throw new ArgumentNullException(nameof(propertyMap)); + } + + var mapWithPersistence = propertyMap as IPropertyMapWithPersistenceMetadata; + if (mapWithPersistence != null) + { + return mapWithPersistence.Persistence; + } + + return propertyMap.Ignored + ? PropertyPersistenceMetadata.Ignored + : PropertyPersistenceMetadata.Default; + } + } +} diff --git a/src/Dapper.FluentMap/MappingRegistry.cs b/src/Dapper.FluentMap/MappingRegistry.cs index 6937925..63634f0 100644 --- a/src/Dapper.FluentMap/MappingRegistry.cs +++ b/src/Dapper.FluentMap/MappingRegistry.cs @@ -756,7 +756,8 @@ private void AddDapperDefaultExplanations( inheritedFrom: null, conventionType: null, constructorParameters: constructorParameters, - materialization: MappingMaterialization.Dapper)); + materialization: MappingMaterialization.Dapper, + persistence: PropertyPersistenceMetadata.Default)); configuredPaths.Add(memberPath); } } @@ -784,7 +785,8 @@ private void AddMemberExplanation( descriptor.InheritedFrom, descriptor.ConventionType, constructorParameters, - materialization)); + materialization, + PropertyMapPersistence.GetPersistence(descriptor.Map))); configuredPaths.Add(memberPath); } diff --git a/test/Dapper.FluentMap.Dommel.Tests/ManualMappingTests.cs b/test/Dapper.FluentMap.Dommel.Tests/ManualMappingTests.cs index e2b06e6..9104b2c 100644 --- a/test/Dapper.FluentMap.Dommel.Tests/ManualMappingTests.cs +++ b/test/Dapper.FluentMap.Dommel.Tests/ManualMappingTests.cs @@ -1,4 +1,5 @@ using Dapper.FluentMap.Dommel.Mapping; +using Dapper.FluentMap.Mapping; using System; using System.ComponentModel.DataAnnotations.Schema; using System.Linq; @@ -124,6 +125,97 @@ public void PropertiesAreNotGenerated() Assert.All(properties, p => Assert.False(p.IsGenerated)); } + [Fact] + public void KeyShouldBeRepresentedInPersistenceMetadataWithoutImplyingIdentity() + { + PreTest(); + + var map = new MapSingleCustomIdPropertyMap(); + var customId = map.PropertyMaps.Single(x => x.PropertyInfo.Name == nameof(DoubleIdEntity.CustomId)); + var persistence = ((IPropertyMapWithPersistenceMetadata)customId).Persistence; + + Assert.True(persistence.IsKey); + Assert.False(persistence.IsIdentity); + Assert.False(persistence.IsGenerated); + Assert.True(persistence.ParticipatesInInsert); + Assert.False(persistence.ParticipatesInUpdate); + Assert.True(persistence.ParticipatesInMaterialization); + } + + [Fact] + public void IdentityShouldBeRepresentedAsGeneratedKeyMetadata() + { + PreTest(); + + var map = new MapWithCustomIdPropertyMap(); + var customId = map.PropertyMaps.Single(); + var persistence = ((IPropertyMapWithPersistenceMetadata)customId).Persistence; + + Assert.True(persistence.IsKey); + Assert.True(persistence.IsIdentity); + Assert.True(persistence.IsGenerated); + Assert.False(persistence.ParticipatesInInsert); + Assert.False(persistence.ParticipatesInUpdate); + Assert.True(persistence.ParticipatesInMaterialization); + } + + [Fact] + public void ComputedShouldBeRepresentedAsGeneratedReadOnlyMetadata() + { + PreTest(); + + var map = new MapComputedProperty(); + var property = map.PropertyMaps.Single(); + var persistence = ((IPropertyMapWithPersistenceMetadata)property).Persistence; + + Assert.True(persistence.IsGenerated); + Assert.True(persistence.IsComputed); + Assert.False(persistence.ParticipatesInInsert); + Assert.False(persistence.ParticipatesInUpdate); + Assert.True(persistence.ParticipatesInMaterialization); + } + + [Fact] + public void GeneratedOptionNoneShouldKeepNonIdentityKeyInsertable() + { + PreTest(); + + var map = new MapCompositeKeyPropertyMap(); + var key = map.PropertyMaps.First(); + var persistence = ((IPropertyMapWithPersistenceMetadata)key).Persistence; + + Assert.True(persistence.IsKey); + Assert.False(persistence.IsIdentity); + Assert.False(persistence.IsGenerated); + Assert.True(persistence.ParticipatesInInsert); + Assert.False(persistence.ParticipatesInUpdate); + } + + [Fact] + public void ComputedKeyShouldThrowConfigurationException() + { + PreTest(); + + var exception = Assert.Throws(() => new InvalidComputedKeyMap()); + + Assert.Contains("computed", exception.Message, StringComparison.OrdinalIgnoreCase); + Assert.Contains("key", exception.Message, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void CoreReadOnlyMetadataShouldBeConsumableByDommelPropertyResolver() + { + PreTest(); + + FluentMapper.Initialize(c => c.AddMap(new CoreReadOnlyMap())); + + var propertyResolver = new Dommel.Resolvers.DommelPropertyResolver(); + var properties = propertyResolver.ResolveProperties(typeof(DoubleIdEntity)); + var name = properties.Single(p => p.Property.Name == nameof(DoubleIdEntity.Name)); + + Assert.True(name.IsGenerated); + } + private static void PreTest() { FluentMapper.EntityMaps.Clear(); @@ -163,5 +255,29 @@ public MapCompositeKeyPropertyMap() Map(p => p.KeyPartTwo).IsKey().SetGeneratedOption(DatabaseGeneratedOption.None); } } + + private class MapComputedProperty : DommelEntityMap + { + public MapComputedProperty() + { + Map(p => p.Name).Computed(); + } + } + + private class InvalidComputedKeyMap : DommelEntityMap + { + public InvalidComputedKeyMap() + { + Map(p => p.CustomId).Computed().IsKey(); + } + } + + private class CoreReadOnlyMap : EntityMap + { + public CoreReadOnlyMap() + { + Map(p => p.Name).ReadOnly(); + } + } } } diff --git a/test/Dapper.FluentMap.Tests/PropertyPersistenceMetadataTests.cs b/test/Dapper.FluentMap.Tests/PropertyPersistenceMetadataTests.cs new file mode 100644 index 0000000..bc4228c --- /dev/null +++ b/test/Dapper.FluentMap.Tests/PropertyPersistenceMetadataTests.cs @@ -0,0 +1,372 @@ +using System; +using System.Linq; +using Dapper.FluentMap.Mapping; +using Xunit; + +namespace Dapper.FluentMap.Tests +{ + public class PropertyPersistenceMetadataTests + { + [Fact] + public void DefaultPropertyMapShouldParticipateInReadInsertAndUpdate() + { + var map = new DefaultPersistenceMap(); + + var persistence = PersistenceOf(map); + + Assert.True(persistence.ParticipatesInMaterialization); + Assert.True(persistence.ParticipatesInInsert); + Assert.True(persistence.ParticipatesInUpdate); + Assert.False(persistence.IgnoredByFluentMap); + Assert.False(persistence.IsGenerated); + Assert.False(persistence.IsKey); + Assert.False(persistence.IsIdentity); + } + + [Fact] + public void ReadOnlyShouldPreserveReadAndExcludeInsertAndUpdate() + { + var map = new ReadOnlyPersistenceMap(); + + var persistence = PersistenceOf(map); + + Assert.True(persistence.ParticipatesInMaterialization); + Assert.False(persistence.ParticipatesInInsert); + Assert.False(persistence.ParticipatesInUpdate); + Assert.False(persistence.IgnoredByFluentMap); + Assert.False(persistence.IsGenerated); + } + + [Fact] + public void ExcludeFromInsertShouldOnlyDisableInsertParticipation() + { + var map = new ExcludeInsertPersistenceMap(); + + var persistence = PersistenceOf(map); + + Assert.True(persistence.ParticipatesInMaterialization); + Assert.False(persistence.ParticipatesInInsert); + Assert.True(persistence.ParticipatesInUpdate); + Assert.False(persistence.IsGenerated); + } + + [Fact] + public void ExcludeFromUpdateShouldOnlyDisableUpdateParticipation() + { + var map = new ExcludeUpdatePersistenceMap(); + + var persistence = PersistenceOf(map); + + Assert.True(persistence.ParticipatesInMaterialization); + Assert.True(persistence.ParticipatesInInsert); + Assert.False(persistence.ParticipatesInUpdate); + } + + [Fact] + public void IgnoreShouldDisableReadInsertAndUpdateParticipation() + { + var map = new IgnorePersistenceMap(); + + var persistence = PersistenceOf(map); + + Assert.True(map.PropertyMaps.Single().Ignored); + Assert.False(persistence.ParticipatesInMaterialization); + Assert.False(persistence.ParticipatesInInsert); + Assert.False(persistence.ParticipatesInUpdate); + Assert.True(persistence.IgnoredByFluentMap); + } + + [Fact] + public void ComputedShouldBeGeneratedReadOnlyPersistenceMetadata() + { + var map = new ComputedPersistenceMap(); + + var persistence = PersistenceOf(map); + + Assert.True(persistence.ParticipatesInMaterialization); + Assert.False(persistence.ParticipatesInInsert); + Assert.False(persistence.ParticipatesInUpdate); + Assert.True(persistence.IsGenerated); + Assert.True(persistence.IsComputed); + Assert.False(persistence.HasDatabaseDefaultOnInsert); + } + + [Fact] + public void DatabaseDefaultOnInsertShouldBeGeneratedAndExcludeOnlyInsertByDefault() + { + var map = new DatabaseDefaultPersistenceMap(); + + var persistence = PersistenceOf(map); + + Assert.True(persistence.ParticipatesInMaterialization); + Assert.False(persistence.ParticipatesInInsert); + Assert.True(persistence.ParticipatesInUpdate); + Assert.True(persistence.IsGenerated); + Assert.False(persistence.IsComputed); + Assert.True(persistence.HasDatabaseDefaultOnInsert); + } + + [Fact] + public void DatabaseDefaultCanBeCombinedWithExcludeFromUpdate() + { + var map = new DatabaseDefaultReadOnlyPersistenceMap(); + + var persistence = PersistenceOf(map); + + Assert.True(persistence.ParticipatesInMaterialization); + Assert.False(persistence.ParticipatesInInsert); + Assert.False(persistence.ParticipatesInUpdate); + Assert.True(persistence.IsGenerated); + Assert.True(persistence.HasDatabaseDefaultOnInsert); + } + + [Fact] + public void DuplicateCompatibleConfigurationShouldBeIdempotent() + { + var map = new DuplicatePersistenceConfigurationMap(); + + var persistence = PersistenceOf(map); + + Assert.True(persistence.ParticipatesInMaterialization); + Assert.False(persistence.ParticipatesInInsert); + Assert.False(persistence.ParticipatesInUpdate); + Assert.False(persistence.IsGenerated); + } + + [Fact] + public void WritePersistenceConfigurationAfterIgnoreShouldThrow() + { + var exception = Assert.Throws(() => new IgnoreThenReadOnlyPersistenceMap()); + + Assert.Contains("Ignored properties", exception.Message); + } + + [Fact] + public void ComputedAndDatabaseDefaultShouldThrow() + { + var exception = Assert.Throws(() => new ComputedThenDefaultPersistenceMap()); + + Assert.Contains("computed", exception.Message, StringComparison.OrdinalIgnoreCase); + Assert.Contains("database default", exception.Message, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void ExplainShouldExposePersistenceMetadata() + { + PreTest(typeof(PersistenceEntity)); + + try + { + FluentMapper.Initialize(c => c.AddMap(new ComputedPersistenceMap())); + + var explanation = FluentMapper.Explain(); + var persistence = explanation.Members.Single(m => m.MemberPath == nameof(PersistenceEntity.CreatedAt)).Persistence; + + Assert.True(persistence.IsComputed); + Assert.False(persistence.ParticipatesInInsert); + Assert.False(persistence.ParticipatesInUpdate); + } + finally + { + PreTest(typeof(PersistenceEntity)); + } + } + + [Fact] + public void InheritedMappingsShouldPreservePersistenceMetadata() + { + PreTest(typeof(PersistenceBaseEntity), typeof(PersistenceDerivedEntity)); + + try + { + FluentMapper.Initialize(c => + { + c.AddMap(new PersistenceBaseMap()); + c.AddMap(new PersistenceDerivedMap()); + }); + + var explanation = FluentMapper.Explain(); + var persistence = explanation.Members.Single(m => m.MemberPath == nameof(PersistenceBaseEntity.CreatedAt)).Persistence; + + Assert.Equal(typeof(PersistenceBaseEntity), explanation.Members.Single(m => m.MemberPath == nameof(PersistenceBaseEntity.CreatedAt)).InheritedFrom); + Assert.True(persistence.ParticipatesInMaterialization); + Assert.False(persistence.ParticipatesInInsert); + Assert.False(persistence.ParticipatesInUpdate); + } + finally + { + PreTest(typeof(PersistenceBaseEntity), typeof(PersistenceDerivedEntity)); + } + } + + [Fact] + public void ProfileMappingsShouldExposeProfileSpecificPersistenceMetadata() + { + PreTest(typeof(ProfilePersistenceEntity)); + + try + { + FluentMapper.Initialize(c => c.AddProfile()); + + var explanation = FluentMapper.Explain(); + var persistence = explanation.Members.Single(m => m.MemberPath == nameof(ProfilePersistenceEntity.UpdatedAt)).Persistence; + + Assert.False(persistence.ParticipatesInInsert); + Assert.True(persistence.ParticipatesInUpdate); + Assert.True(persistence.HasDatabaseDefaultOnInsert); + } + finally + { + PreTest(typeof(ProfilePersistenceEntity)); + } + } + + private static PropertyPersistenceMetadata PersistenceOf(IEntityMap map) + { + return ((IPropertyMapWithPersistenceMetadata)map.PropertyMaps.Single()).Persistence; + } + + private static void PreTest(params Type[] types) + { + FluentMapper.Reset(types); + } + + private sealed class PersistenceEntity + { + public int Id { get; set; } + + public DateTime CreatedAt { get; set; } + } + + private sealed class DefaultPersistenceMap : EntityMap + { + public DefaultPersistenceMap() + { + Map(e => e.CreatedAt).ToColumn("created_at"); + } + } + + private sealed class ReadOnlyPersistenceMap : EntityMap + { + public ReadOnlyPersistenceMap() + { + Map(e => e.CreatedAt).ToColumn("created_at").ReadOnly(); + } + } + + private sealed class ExcludeInsertPersistenceMap : EntityMap + { + public ExcludeInsertPersistenceMap() + { + Map(e => e.CreatedAt).ExcludeFromInsert(); + } + } + + private sealed class ExcludeUpdatePersistenceMap : EntityMap + { + public ExcludeUpdatePersistenceMap() + { + Map(e => e.CreatedAt).ExcludeFromUpdate(); + } + } + + private sealed class IgnorePersistenceMap : EntityMap + { + public IgnorePersistenceMap() + { + Map(e => e.CreatedAt).Ignore(); + } + } + + private sealed class ComputedPersistenceMap : EntityMap + { + public ComputedPersistenceMap() + { + Map(e => e.CreatedAt).Computed(); + } + } + + private sealed class DatabaseDefaultPersistenceMap : EntityMap + { + public DatabaseDefaultPersistenceMap() + { + Map(e => e.CreatedAt).DatabaseDefaultOnInsert(); + } + } + + private sealed class DatabaseDefaultReadOnlyPersistenceMap : EntityMap + { + public DatabaseDefaultReadOnlyPersistenceMap() + { + Map(e => e.CreatedAt).DatabaseDefaultOnInsert().ExcludeFromUpdate(); + } + } + + private sealed class DuplicatePersistenceConfigurationMap : EntityMap + { + public DuplicatePersistenceConfigurationMap() + { + Map(e => e.CreatedAt).ReadOnly().ExcludeFromInsert().ExcludeFromUpdate().ReadOnly(); + } + } + + private sealed class IgnoreThenReadOnlyPersistenceMap : EntityMap + { + public IgnoreThenReadOnlyPersistenceMap() + { + Map(e => e.CreatedAt).Ignore().ReadOnly(); + } + } + + private sealed class ComputedThenDefaultPersistenceMap : EntityMap + { + public ComputedThenDefaultPersistenceMap() + { + Map(e => e.CreatedAt).Computed().DatabaseDefaultOnInsert(); + } + } + + private class PersistenceBaseEntity + { + public DateTime CreatedAt { get; set; } + } + + private sealed class PersistenceDerivedEntity : PersistenceBaseEntity + { + public string Name { get; set; } + } + + private sealed class PersistenceBaseMap : EntityMap + { + public PersistenceBaseMap() + { + Map(e => e.CreatedAt).ReadOnly(); + } + } + + private sealed class PersistenceDerivedMap : EntityMap + { + public PersistenceDerivedMap() + { + IncludeBase(); + } + } + + private sealed class PersistenceProfile : IMappingProfile + { + } + + private sealed class ProfilePersistenceEntity + { + public DateTime UpdatedAt { get; set; } + } + + private sealed class ProfilePersistenceMap : EntityMap, IProfileMap + { + public ProfilePersistenceMap() + { + Map(e => e.UpdatedAt).DatabaseDefaultOnInsert(); + } + } + } +} From cf1fcce99646131455b599d1fbdd2f9b724cb2f3 Mon Sep 17 00:00:00 2001 From: Rodrigo de Oliveira Date: Tue, 28 Jul 2026 11:58:49 -0300 Subject: [PATCH 10/49] fix(materialization): preserve read and ignore semantics --- .sdd/etapa-8/01-historical-issues.md | 22 ++- .sdd/etapa-8/04-read-semantics.md | 154 ++++++++++++++++++ .sdd/etapa-8/STATUS.md | 37 ++++- .../MappingRegistrationGenerator.cs | 24 +++ .../GeneratedRegistrationIntegrationTests.cs | 63 ++++++- .../MappingRegistrationGeneratorTests.cs | 52 ++++++ .../DapperIntegrationTests.cs | 78 +++++++++ 7 files changed, 421 insertions(+), 9 deletions(-) create mode 100644 .sdd/etapa-8/04-read-semantics.md diff --git a/.sdd/etapa-8/01-historical-issues.md b/.sdd/etapa-8/01-historical-issues.md index 0aa093e..172b232 100644 --- a/.sdd/etapa-8/01-historical-issues.md +++ b/.sdd/etapa-8/01-historical-issues.md @@ -270,14 +270,18 @@ rejeita membros nao propriedade com `ArgumentException`. ### Ainda reproduzivel? -Nao para os cenarios cobertos: existem testes para `Format`, `Duration` e -colisao com membro de `string`. +Nao para os cenarios cobertos: existem testes para `Format`, `Duration`, colisao +com membro de `string` e materializacao Dapper real usando outro nome de membro +de `string`, preservando a categoria sem depender de excecao especial para +`Format`. ### Cobertura de testes existente - `ReflectionHelperTests.GetMemberInfo_ReturnsProperty_WhenPropertyNameMatchesSystemMember` - `ReflectionHelperTests.GetMemberInfo_ReturnsValueTypeProperty_WhenPropertyNameMatchesSystemMember` - `ReflectionHelperTests.GetMemberInfo_ReturnsValueTypeProperty_WithSystemTypeNames` +- `DapperIntegrationTests.ExpressionResolvedPropertyShouldMaterializeWhenNameCollidesWithStringMember` +- `DapperIntegrationTests.ExpressionResolvedPropertyShouldMaterializeWhenNameCollidesWithAnotherStringMember` ### Relacao com a Etapa 8 @@ -328,6 +332,8 @@ filtradas por `type.GetProperties()`. - `NestedObjectMaterializationTests.QueryMappedShouldPreserveSameTerminalMemberPaths` - `MappingRegistrationGeneratorTests.SameTerminalNestedPathsShouldUseFullMemberPathsInDescriptor` - `GeneratedRegistrationIntegrationTests` para same terminal. +- `GeneratedRegistrationIntegrationTests.GeneratedQueryMappedShouldMatchRuntimeFallbackForEquivalentComplexShapes` + valida equivalencia runtime/generated para `Rank.Level` e `Seniority.Level`. ### Relacao com a Etapa 8 @@ -374,16 +380,20 @@ incompleto. ### Ainda reproduzivel? -Provavelmente nao no core atual; existem regressões para ignored no generated -path e testes basicos de ignore. Ainda seria util adicionar regression historico -especifico para `Dapper.Query()` com coluna ignorada e propriedades get-only, -se a etapa alterar a area. +Nao para o core atual nos cenarios cobertos. O Prompt 8.3 adicionou regressao +com `Dapper.Query()` selecionando uma coluna ignorada; a propriedade permanece +com valor inicial e nao ha `NotImplementedException`. Tambem ha cobertura para +ignored no generated path e equivalencia runtime/generated. ### Cobertura de testes existente - `ManualMappingTests.PropertyShouldBeIgnored` - `GeneratedRegistrationIntegrationTests` valida que `Secret` ignorado permanece com valor inicial. +- `DapperIntegrationTests.IgnoredExplicitMappingShouldNotMaterializeSelectedColumn` + cobre a regressao historica equivalente ao PR #131. +- `GeneratedRegistrationIntegrationTests.GeneratedQueryMappedShouldMatchRuntimeFallbackForEquivalentComplexShapes` + compara generated e runtime fallback para propriedade ignorada. ### Relacao com a Etapa 8 diff --git a/.sdd/etapa-8/04-read-semantics.md b/.sdd/etapa-8/04-read-semantics.md new file mode 100644 index 0000000..9516c2f --- /dev/null +++ b/.sdd/etapa-8/04-read-semantics.md @@ -0,0 +1,154 @@ +# Etapa 8 - Read Semantics & Materialization Compatibility + +Status: implementado no Prompt 8.3. + +## Objetivo + +Definir a semantica de leitura apos a introducao de metadata de persistencia, +preservando compatibilidade do FluentMap como biblioteca publica. + +A regra central e: + +```text +Ignore altera leitura/materializacao. +ReadOnly, ExcludeFromInsert, ExcludeFromUpdate, Computed e DatabaseDefaultOnInsert +alteram escrita/metadata de persistencia, nao leitura. +``` + +## Estados de propriedade + +| Estado | Materializa? | Insert | Update | Generated | Observacao | +| --- | --- | --- | --- | --- | --- | +| normal | sim | sim | sim | nao | Mapping padrao. | +| ignored | nao | nao | nao | nao | `Ignore()` preserva o significado historico. | +| read-only | sim | nao | nao | nao | `ReadOnly()` nao e alias de `Ignore()`. | +| insert-excluded | sim | nao | sim | nao | `ExcludeFromInsert()` preserva update e leitura. | +| update-excluded | sim | sim | nao | nao | `ExcludeFromUpdate()` preserva insert e leitura. | +| computed | sim | nao | nao | sim | `Computed()` representa valor lido, gerado pelo banco e nao escrito. | +| generated/default | sim | nao | sim por default | sim | `DatabaseDefaultOnInsert()` omite insert, mas preserva update ate que `ExcludeFromUpdate()` seja composto. | + +`PropertyPersistenceMetadata.ParticipatesInMaterialization` e a dimensao de +leitura. As dimensoes `ParticipatesInInsert`, `ParticipatesInUpdate`, +`IsGenerated`, `IsComputed` e `HasDatabaseDefaultOnInsert` nao devem ser usadas +por materializadores para decidir se uma propriedade sera preenchida. + +## Dapper normal mapping + +`Dapper.Query()` usa o type map global instalado pelo FluentMap para +propriedades no nivel raiz e constructor mapping root-level. + +- normal: coluna configurada preenche a propriedade ou parametro de construtor. +- ignored: coluna configurada retorna sentinela interna e nao cai para o mapping + default do Dapper; a propriedade permanece com valor inicial/default. +- read-only: coluna configurada preenche normalmente. +- insert-excluded: coluna configurada preenche normalmente. +- update-excluded: coluna configurada preenche normalmente. +- computed: coluna configurada preenche normalmente. +- generated/default: coluna configurada preenche normalmente. + +Nested paths nao sao materializados por `Dapper.Query()`; eles sao protegidos +por sentinela para evitar fallback incorreto para o membro terminal. + +## QueryMapped runtime + +`QueryMapped*` usa `NestedMaterializationPlan` quando nao ha generated +materializer compativel. + +- normal: entra no plano de materializacao. +- ignored: e pulado durante a criacao do plano. +- read-only: entra no plano. +- insert-excluded: entra no plano. +- update-excluded: entra no plano. +- computed: entra no plano. +- generated/default: entra no plano. + +Essa regra vale para propriedades flat, nested mappings, Value Objects +construidos por componentes, tipos imutaveis e profiles. + +## Generated materialization + +Generated materializers sao uma otimizacao de leitura `IDataRecord -> entidade`. +Eles observam apenas o shape de colunas e a semantica de leitura. + +- normal: descriptor usa `GeneratedMaterializerColumn.Map(column, memberPath)`. +- ignored: descriptor usa `GeneratedMaterializerColumn.Ignore(column)` e o + delegate nao atribui o membro. +- read-only: descriptor usa `Map`, nao `Ignore`. +- insert-excluded: descriptor usa `Map`, nao `Ignore`. +- update-excluded: descriptor usa `Map`, nao `Ignore`. +- computed: descriptor usa `Map`, nao `Ignore`. +- generated/default: descriptor usa `Map`, nao `Ignore`. + +O source generator deve aceitar chamadas fluent de escrita conhecidas como +neutras para leitura. A presenca dessas chamadas nao deve impedir a emissao de +materializer gerado quando o restante do map e estaticamente suportado. + +O runtime valida descriptors gerados contra o mapping efetivo antes do dispatch. +Essa validacao deve rejeitar divergencias de `Ignore()` e de member path, mas +nao deve rejeitar apenas porque metadata de insert/update mudou. + +## Constructor mapping + +Para `Dapper.Query()`, o constructor type map do FluentMap aplica apenas +mapeamentos root-level simples: + +- ignored: nao participa da escolha de construtor nem do binding de parametro. +- read-only/computed/generated/excluded: participam como propriedade normal. + +Para `QueryMapped*`, construtores root, nested e de Value Objects seguem a mesma +regra de leitura do plano runtime/generated: + +- ignored nao fornece argumento; +- write exclusions continuam fornecendo argumento quando a coluna esta presente. + +## Nested mapping + +Nested member paths sao identificados por caminho completo, por exemplo: + +```text +Rank.Level +Seniority.Level +``` + +O terminal `Level` nao e suficiente para identidade de mapping. Essa regra vale +para runtime materializer, generated descriptor, diagnostics e validacao de +duplicidade. + +Write metadata anexada a um nested path nao altera a criacao de objetos +intermediarios nem a regra de null subtree. Apenas `Ignore()` remove o path da +materializacao configurada. + +## Value Objects + +Value Objects mapeados por componentes continuam sendo materializados por +construtores publicos compativeis no `QueryMapped*`. + +- ignored: componente nao entra no plano e nao participa do construtor. +- read-only/computed/generated/excluded: componente entra no plano como qualquer + outro componente de leitura. + +Value Objects escalares por TypeHandler continuam no boundary do Dapper e nao +sao transformados por metadata de escrita. + +## Profiles + +Profiles sao query-scoped em `QueryMapped()`. + +Cada profile possui mapping e generated descriptors proprios. A semantica de +leitura e a mesma: + +- `Ignore()` no profile remove leitura naquele profile. +- `ReadOnly()` e demais write semantics no profile continuam materializando. +- O profile nao altera o type map global usado por `Dapper.Query()`. + +## Regressions historicas protegidas + +- #114: expression parsing usa o `MemberInfo` real da expression tree e valida + `PropertyInfo`, evitando confusao com metodos como membros de `string` ou + `TimeSpan`. +- #126: member paths aninhados com mesmo terminal continuam distintos no runtime + e no generated path. +- #133: `Ignore()` nao usa `PropertyInfo` falso/incompleto; Dapper normal mapping + pode ver a coluna ignorada sem `NotImplementedException`, e a propriedade nao e + preenchida. + diff --git a/.sdd/etapa-8/STATUS.md b/.sdd/etapa-8/STATUS.md index 0e7edb3..9b5109f 100644 --- a/.sdd/etapa-8/STATUS.md +++ b/.sdd/etapa-8/STATUS.md @@ -69,6 +69,38 @@ execucao de CRUD ao core. contem `lib/netstandard2.0/Dapper.FluentMap.dll`, `lib/netstandard2.0/Dapper.FluentMap.xml`, nuspec e metadados NuGet; dependencia `Dapper` 2.1.79 preservada. +- Criado `.sdd/etapa-8/04-read-semantics.md`. +- Ajustado source generator para tratar `ExcludeFromInsert()`, + `ExcludeFromUpdate()`, `ReadOnly()`, `Computed()` e + `DatabaseDefaultOnInsert()` como neutros para materializacao gerada. +- Preservado `Ignore()` como unica chamada da DSL atual que transforma coluna + configurada em `GeneratedMaterializerColumn.Ignore(...)`. +- Adicionada regressao de `Dapper.Query()` para coluna ignorada selecionada, + cobrindo a categoria historica da issue #133. +- Adicionada cobertura de colisao de nome de propriedade com membro de `string` + sem depender apenas de `Format`, cobrindo a categoria historica da issue #114. +- Ampliada equivalencia runtime/generated para propriedade normal, ignored, + read-only, computed, database-default-on-insert, insert-excluded e + update-excluded. +- Mantida cobertura runtime/generated existente para nested, immutable, + Value Objects, profiles e member paths `Rank.Level`/`Seniority.Level`. +- Executado `dotnet test .\test\Dapper.FluentMap.Generators.Tests\Dapper.FluentMap.Generators.Tests.csproj --configuration Release --filter "FullyQualifiedName~MappingRegistrationGeneratorTests"`: + sucesso, 23 testes aprovados. +- Executado `dotnet test .\test\Dapper.FluentMap.GeneratedRegistration.Tests\Dapper.FluentMap.GeneratedRegistration.Tests.csproj --configuration Release --filter "FullyQualifiedName~GeneratedRegistrationIntegrationTests"`: + sucesso, 2 testes aprovados. +- Executado `dotnet test .\test\Dapper.FluentMap.Tests\Dapper.FluentMap.Tests.csproj --configuration Release --filter "FullyQualifiedName~DapperIntegrationTests"`: + sucesso, 9 testes aprovados. +- Executado `dotnet restore .\Dapper.FluentMap.sln`: sucesso. +- Executado `dotnet build .\Dapper.FluentMap.sln --configuration Release --no-restore`: + sucesso, 0 warnings, 0 errors. +- Executado `dotnet test .\Dapper.FluentMap.sln --configuration Release --no-build`: + sucesso, 277 testes aprovados. +- Executado smoke de benchmark: + `dotnet run --configuration Release --project .\benchmarks\Dapper.FluentMap.Benchmarks\Dapper.FluentMap.Benchmarks.csproj -- --filter "*MaterializationSteadyStateBenchmarks.QueryMappedSimple*" --job Dry --warmupCount 1 --minIterationCount 1 --maxIterationCount 2`. + Resultado observado: `QueryMappedSimple` generated 3.716 ms / 362.73 KB e + `QueryMappedSimpleRuntimeFallback` 4.435 ms / 361.53 KB. BenchmarkDotNet + alertou que a iteracao unica e curta demais para conclusao estatistica; como + smoke, nao indicou regressao evidente do hot path. ## Em andamento @@ -111,6 +143,8 @@ Nenhum apos a validacao final deste prompt. preservar. - #133 `Ignore()` causando `NotImplementedException`: ja resolvido para bug original, preservar. +- Prompt 8.3 adicionou cobertura explicita para #114, #126 e #133 na semantica + de leitura/materializacao. ## Riscos conhecidos @@ -150,7 +184,8 @@ Nenhum apos a validacao final deste prompt. - `src/Dapper.FluentMap.Dommel/Resolvers/DommelColumnNameResolver.cs` - `src/Dapper.FluentMap.Generators/MappingRegistrationGenerator.cs` - `src/Dapper.FluentMap.Analyzers/FluentMapConfigurationAnalyzer.cs` +- `.sdd/etapa-8/04-read-semantics.md` ## Ultimo prompt executado -Ultimo prompt executado: 8.2 +Ultimo prompt executado: 8.3 diff --git a/src/Dapper.FluentMap.Generators/MappingRegistrationGenerator.cs b/src/Dapper.FluentMap.Generators/MappingRegistrationGenerator.cs index 649388d..9facc1e 100644 --- a/src/Dapper.FluentMap.Generators/MappingRegistrationGenerator.cs +++ b/src/Dapper.FluentMap.Generators/MappingRegistrationGenerator.cs @@ -957,6 +957,10 @@ private static bool TryCreateDirectMapInvocation( { ignored = true; } + else if (IsReadNeutralPersistenceInvocation(chainedMethod)) + { + // Write-only metadata does not change the generated read materializer. + } else { skipReason = "the map chain uses an unsupported mapping method"; @@ -1333,6 +1337,26 @@ private static bool IsIgnoreInvocation(IMethodSymbol method) return method != null && method.Name == "Ignore" && method.Parameters.Length == 0; } + private static bool IsReadNeutralPersistenceInvocation(IMethodSymbol method) + { + if (method == null || method.Parameters.Length != 0) + { + return false; + } + + switch (method.Name) + { + case "ExcludeFromInsert": + case "ExcludeFromUpdate": + case "ReadOnly": + case "Computed": + case "DatabaseDefaultOnInsert": + return true; + default: + return false; + } + } + private static bool IsEntityMapInterface(INamedTypeSymbol type) { return type.OriginalDefinition.MetadataName == "IEntityMap`1" && diff --git a/test/Dapper.FluentMap.GeneratedRegistration.Tests/GeneratedRegistrationIntegrationTests.cs b/test/Dapper.FluentMap.GeneratedRegistration.Tests/GeneratedRegistrationIntegrationTests.cs index 6c91e6e..1d284eb 100644 --- a/test/Dapper.FluentMap.GeneratedRegistration.Tests/GeneratedRegistrationIntegrationTests.cs +++ b/test/Dapper.FluentMap.GeneratedRegistration.Tests/GeneratedRegistrationIntegrationTests.cs @@ -55,6 +55,8 @@ public void GeneratedRegistrationShouldWorkWithDapperAndExistingMappingFeatures( "SELECT 'Profile City' AS legacy_city;"); var ignored = connection.QueryMappedSingle( "SELECT 17 AS customer_id, 'do-not-map' AS secret;"); + var readSemantics = connection.QueryMappedSingle( + "SELECT 18 AS customer_id, 'Normal' AS normal_name, 'Read' AS read_only_name, 123 AS computed_total, '2026-07-28' AS created_at, 'Insert kept for read' AS insert_excluded, 'Update kept for read' AS update_excluded, 'do-not-map' AS secret;"); Assert.Equal(7, customer.Id); Assert.Equal("Ada", customer.Name); @@ -84,6 +86,14 @@ public void GeneratedRegistrationShouldWorkWithDapperAndExistingMappingFeatures( Assert.Equal("Profile City", profiledNested.Address.City); Assert.Equal(17, ignored.Id); Assert.Equal("initial", ignored.Secret); + Assert.Equal(18, readSemantics.Id); + Assert.Equal("Normal", readSemantics.NormalName); + Assert.Equal("Read", readSemantics.ReadOnlyName); + Assert.Equal(123, readSemantics.ComputedTotal); + Assert.Equal("2026-07-28", readSemantics.CreatedAt); + Assert.Equal("Insert kept for read", readSemantics.InsertExcluded); + Assert.Equal("Update kept for read", readSemantics.UpdateExcluded); + Assert.Equal("initial", readSemantics.Secret); Assert.Equal(0, FluentMapper.Registry.MaterializationPlanCacheEntryCount); var fallback = connection.QueryMappedSingle( @@ -113,6 +123,7 @@ public void GeneratedQueryMappedShouldMatchRuntimeFallbackForEquivalentComplexSh GeneratedValueObjectCustomer generatedValueObject; GeneratedSameTerminalCustomer generatedSameTerminal; GeneratedProfileNestedCustomer generatedProfileNested; + GeneratedReadSemanticsCustomer generatedReadSemantics; FluentMapper.Initialize(configuration => configuration.AddGeneratedMappings()); @@ -128,6 +139,8 @@ public void GeneratedQueryMappedShouldMatchRuntimeFallbackForEquivalentComplexSh "SELECT 3 AS rank_level, 8 AS seniority_level;"); generatedProfileNested = connection.QueryMappedSingle( "SELECT 'Profile City' AS legacy_city;"); + generatedReadSemantics = connection.QueryMappedSingle( + "SELECT 24 AS customer_id, 'Normal' AS normal_name, 'Read' AS read_only_name, 987 AS computed_total, '2026-07-28' AS created_at, 'Insert kept for read' AS insert_excluded, 'Update kept for read' AS update_excluded, 'do-not-map' AS secret;"); Assert.Equal(0, FluentMapper.Registry.MaterializationPlanCacheEntryCount); } @@ -140,6 +153,7 @@ public void GeneratedQueryMappedShouldMatchRuntimeFallbackForEquivalentComplexSh configuration.AddMap(); configuration.AddMap(); configuration.AddMap(); + configuration.AddMap(); configuration.AddProfile(); }); @@ -155,6 +169,8 @@ public void GeneratedQueryMappedShouldMatchRuntimeFallbackForEquivalentComplexSh "SELECT 3 AS rank_level, 8 AS seniority_level;"); var runtimeProfileNested = connection.QueryMappedSingle( "SELECT 'Profile City' AS legacy_city;"); + var runtimeReadSemantics = connection.QueryMappedSingle( + "SELECT 24 AS customer_id, 'Normal' AS normal_name, 'Read' AS read_only_name, 987 AS computed_total, '2026-07-28' AS created_at, 'Insert kept for read' AS insert_excluded, 'Update kept for read' AS update_excluded, 'do-not-map' AS secret;"); Assert.Equal(generatedImmutable.Id, runtimeImmutable.Id); Assert.Equal(generatedImmutable.Name, runtimeImmutable.Name); @@ -165,7 +181,15 @@ public void GeneratedQueryMappedShouldMatchRuntimeFallbackForEquivalentComplexSh Assert.Equal(generatedSameTerminal.Rank.Level, runtimeSameTerminal.Rank.Level); Assert.Equal(generatedSameTerminal.Seniority.Level, runtimeSameTerminal.Seniority.Level); Assert.Equal(generatedProfileNested.Address.City, runtimeProfileNested.Address.City); - Assert.Equal(5, FluentMapper.Registry.MaterializationPlanCacheEntryCount); + Assert.Equal(generatedReadSemantics.Id, runtimeReadSemantics.Id); + Assert.Equal(generatedReadSemantics.NormalName, runtimeReadSemantics.NormalName); + Assert.Equal(generatedReadSemantics.ReadOnlyName, runtimeReadSemantics.ReadOnlyName); + Assert.Equal(generatedReadSemantics.ComputedTotal, runtimeReadSemantics.ComputedTotal); + Assert.Equal(generatedReadSemantics.CreatedAt, runtimeReadSemantics.CreatedAt); + Assert.Equal(generatedReadSemantics.InsertExcluded, runtimeReadSemantics.InsertExcluded); + Assert.Equal(generatedReadSemantics.UpdateExcluded, runtimeReadSemantics.UpdateExcluded); + Assert.Equal(generatedReadSemantics.Secret, runtimeReadSemantics.Secret); + Assert.Equal(6, FluentMapper.Registry.MaterializationPlanCacheEntryCount); } } finally @@ -196,7 +220,8 @@ private static void ResetMapper() typeof(GeneratedValueObjectCustomer), typeof(GeneratedSameTerminalCustomer), typeof(GeneratedProfileNestedCustomer), - typeof(GeneratedIgnoredCustomer)); + typeof(GeneratedIgnoredCustomer), + typeof(GeneratedReadSemanticsCustomer)); } } @@ -443,4 +468,38 @@ public GeneratedIgnoredCustomerMap() Map(customer => customer.Secret).ToColumn("secret").Ignore(); } } + + public sealed class GeneratedReadSemanticsCustomer + { + public int Id { get; set; } + + public string NormalName { get; set; } + + public string ReadOnlyName { get; set; } + + public int ComputedTotal { get; set; } + + public string CreatedAt { get; set; } + + public string InsertExcluded { get; set; } + + public string UpdateExcluded { get; set; } + + public string Secret { get; set; } = "initial"; + } + + public sealed class GeneratedReadSemanticsCustomerMap : EntityMap + { + public GeneratedReadSemanticsCustomerMap() + { + Map(customer => customer.Id).ToColumn("customer_id"); + Map(customer => customer.NormalName).ToColumn("normal_name"); + Map(customer => customer.ReadOnlyName).ToColumn("read_only_name").ReadOnly(); + Map(customer => customer.ComputedTotal).ToColumn("computed_total").Computed(); + Map(customer => customer.CreatedAt).ToColumn("created_at").DatabaseDefaultOnInsert(); + Map(customer => customer.InsertExcluded).ToColumn("insert_excluded").ExcludeFromInsert(); + Map(customer => customer.UpdateExcluded).ToColumn("update_excluded").ExcludeFromUpdate(); + Map(customer => customer.Secret).ToColumn("secret").Ignore(); + } + } } diff --git a/test/Dapper.FluentMap.Generators.Tests/MappingRegistrationGeneratorTests.cs b/test/Dapper.FluentMap.Generators.Tests/MappingRegistrationGeneratorTests.cs index 71330e6..bc30284 100644 --- a/test/Dapper.FluentMap.Generators.Tests/MappingRegistrationGeneratorTests.cs +++ b/test/Dapper.FluentMap.Generators.Tests/MappingRegistrationGeneratorTests.cs @@ -198,6 +198,58 @@ public CustomerMap() Assert.DoesNotContain("entity.Secret =", result.GeneratedSource, StringComparison.Ordinal); } + [Fact] + public void WritePersistenceSemanticsShouldNotDisableGeneratedReadMaterializer() + { + var source = @" +using Dapper.FluentMap.Mapping; + +public sealed class Customer +{ + public int Id { get; set; } + + public string ReadOnlyName { get; set; } + + public decimal ComputedTotal { get; set; } + + public string CreatedAt { get; set; } + + public string InsertExcluded { get; set; } + + public string UpdateExcluded { get; set; } + + public string Secret { get; set; } +} + +public sealed class CustomerMap : EntityMap +{ + public CustomerMap() + { + Map(customer => customer.Id).ToColumn(""customer_id""); + Map(customer => customer.ReadOnlyName).ToColumn(""read_only_name"").ReadOnly(); + Map(customer => customer.ComputedTotal).ToColumn(""computed_total"").Computed(); + Map(customer => customer.CreatedAt).ToColumn(""created_at"").DatabaseDefaultOnInsert(); + Map(customer => customer.InsertExcluded).ToColumn(""insert_excluded"").ExcludeFromInsert(); + Map(customer => customer.UpdateExcluded).ToColumn(""update_excluded"").ExcludeFromUpdate(); + Map(customer => customer.Secret).ToColumn(""secret"").Ignore(); + } +}"; + + var result = RunGenerator(source); + + Assert.Empty(result.DfmDiagnostics); + Assert.Contains(".AddGeneratedMaterializer(", result.GeneratedSource, StringComparison.Ordinal); + Assert.Contains("GeneratedMaterializerColumn.Map(\"read_only_name\", \"ReadOnlyName\")", result.GeneratedSource, StringComparison.Ordinal); + Assert.Contains("GeneratedMaterializerColumn.Map(\"computed_total\", \"ComputedTotal\")", result.GeneratedSource, StringComparison.Ordinal); + Assert.Contains("GeneratedMaterializerColumn.Map(\"created_at\", \"CreatedAt\")", result.GeneratedSource, StringComparison.Ordinal); + Assert.Contains("GeneratedMaterializerColumn.Map(\"insert_excluded\", \"InsertExcluded\")", result.GeneratedSource, StringComparison.Ordinal); + Assert.Contains("GeneratedMaterializerColumn.Map(\"update_excluded\", \"UpdateExcluded\")", result.GeneratedSource, StringComparison.Ordinal); + Assert.Contains("GeneratedMaterializerColumn.Ignore(\"secret\")", result.GeneratedSource, StringComparison.Ordinal); + Assert.Contains("entity.ReadOnlyName = Read(record, 1);", result.GeneratedSource, StringComparison.Ordinal); + Assert.Contains("entity.ComputedTotal = Read(record, 2);", result.GeneratedSource, StringComparison.Ordinal); + Assert.DoesNotContain("entity.Secret =", result.GeneratedSource, StringComparison.Ordinal); + } + [Fact] public void MultipleMappingsShouldBeGeneratedInDeterministicOrder() { diff --git a/test/Dapper.FluentMap.Tests/DapperIntegrationTests.cs b/test/Dapper.FluentMap.Tests/DapperIntegrationTests.cs index e0ed2c7..8da37d4 100644 --- a/test/Dapper.FluentMap.Tests/DapperIntegrationTests.cs +++ b/test/Dapper.FluentMap.Tests/DapperIntegrationTests.cs @@ -56,6 +56,31 @@ public void ExplicitMappingShouldMaterializeConfiguredColumn() } } + [Fact] + [Trait("Category", "Integration")] + public void IgnoredExplicitMappingShouldNotMaterializeSelectedColumn() + { + ResetMapper(typeof(IgnoredMappingEntity)); + + try + { + FluentMapper.Initialize(c => c.AddMap(new IgnoredMappingMap())); + + using (var connection = OpenConnection()) + { + var entity = connection.QuerySingle( + "SELECT 9 AS person_id, 'do-not-map' AS secret;"); + + Assert.Equal(9, entity.Id); + Assert.Equal("initial", entity.Secret); + } + } + finally + { + ResetMapper(typeof(IgnoredMappingEntity)); + } + } + [Fact] [Trait("Category", "Integration")] public void ConventionShouldMaterializeConfiguredColumns() @@ -163,6 +188,30 @@ public void ExpressionResolvedPropertyShouldMaterializeWhenNameCollidesWithStrin } } + [Fact] + [Trait("Category", "Integration")] + public void ExpressionResolvedPropertyShouldMaterializeWhenNameCollidesWithAnotherStringMember() + { + ResetMapper(typeof(AnotherStringMemberNameCollisionEntity)); + + try + { + FluentMapper.Initialize(c => c.AddMap(new AnotherStringMemberNameCollisionMap())); + + using (var connection = OpenConnection()) + { + var entity = connection.QuerySingle( + "SELECT 'joined' AS join_text;"); + + Assert.Equal("joined", entity.Join); + } + } + finally + { + ResetMapper(typeof(AnotherStringMemberNameCollisionEntity)); + } + } + [Fact] [Trait("Category", "Integration")] public void CaseInsensitiveExplicitMappingShouldMaterializeColumnWithDifferentCase() @@ -221,6 +270,22 @@ public ExplicitMappingMap() } } + private class IgnoredMappingEntity + { + public int Id { get; set; } + + public string Secret { get; set; } = "initial"; + } + + private class IgnoredMappingMap : EntityMap + { + public IgnoredMappingMap() + { + Map(e => e.Id).ToColumn("person_id"); + Map(e => e.Secret).ToColumn("secret").Ignore(); + } + } + private class ConventionEntity { public int Id { get; set; } @@ -271,6 +336,19 @@ public StringMemberNameCollisionMap() } } + private class AnotherStringMemberNameCollisionEntity + { + public string Join { get; set; } + } + + private class AnotherStringMemberNameCollisionMap : EntityMap + { + public AnotherStringMemberNameCollisionMap() + { + Map(e => e.Join).ToColumn("join_text"); + } + } + private class CaseInsensitiveEntity { public int Id { get; set; } From 9061d3eab1c3a7fe5b79bbaa5c7d3a0f909d8954 Mon Sep 17 00:00:00 2001 From: Rodrigo de Oliveira Date: Tue, 28 Jul 2026 12:17:45 -0300 Subject: [PATCH 11/49] feat(dommel): honor property persistence semantics --- .sdd/etapa-8/01-historical-issues.md | 49 +- .../etapa-8/05-dommel-persistence-behavior.md | 105 ++++ .sdd/etapa-8/STATUS.md | 55 +- README.md | 16 + .../FluentMapConfigurationExtensions.cs | 1 + .../Mapping/DommelPropertyMap.cs | 22 +- .../Resolvers/DommelColumnNameResolver.cs | 5 +- .../Resolvers/DommelKeyPropertyResolver.cs | 4 +- .../Resolvers/DommelPersistenceMetadata.cs | 122 +++++ .../Resolvers/DommelPersistenceSqlBuilder.cs | 64 +++ .../Resolvers/DommelPropertyResolver.cs | 6 +- .../Dapper.FluentMap.Dommel.Tests.csproj | 2 + .../DommelPersistenceIntegrationTests.cs | 471 ++++++++++++++++++ .../ManualMappingTests.cs | 6 +- 14 files changed, 881 insertions(+), 47 deletions(-) create mode 100644 .sdd/etapa-8/05-dommel-persistence-behavior.md create mode 100644 src/Dapper.FluentMap.Dommel/Resolvers/DommelPersistenceMetadata.cs create mode 100644 src/Dapper.FluentMap.Dommel/Resolvers/DommelPersistenceSqlBuilder.cs create mode 100644 test/Dapper.FluentMap.Dommel.Tests/DommelPersistenceIntegrationTests.cs diff --git a/.sdd/etapa-8/01-historical-issues.md b/.sdd/etapa-8/01-historical-issues.md index 172b232..372ae5c 100644 --- a/.sdd/etapa-8/01-historical-issues.md +++ b/.sdd/etapa-8/01-historical-issues.md @@ -61,11 +61,16 @@ nao cobre expressivamente "exclude insert" versus "exclude update". E a issue que melhor expressa o objetivo central: separar leitura/materializacao de persistencia escrita. -### Decisao +### Status apos Prompt 8.4 -Resolver por nova arquitetura. +Resolved. -### Evidencia +Evidencia: `ReadOnly()` e consumido pelo `Dapper.FluentMap.Dommel` como +`SELECT=yes`, `INSERT=no`, `UPDATE=no`. A regressao real esta em +`DommelPersistenceIntegrationTests.InsertUpdateAndSelectShouldHonorPropertyPersistenceMetadata`, +que valida insert, update e leitura posterior via Dommel/SQLite. + +### Evidencia historica - `src/Dapper.FluentMap/Mapping/PropertyMap.cs` - `src/Dapper.FluentMap/Compatibility/DapperFluentPropertyTypeMap.cs` @@ -123,11 +128,20 @@ Exige modelar `Key` e `Identity` como dimensoes independentes. Uma key nao identity deve ser representavel como `Read=yes`, `Insert=yes`, `Key=yes`, `Identity=no`. -### Decisao +### Status apos Prompt 8.4 -Resolver por nova arquitetura e manter regression tests. +Regression covered. -### Evidencia +Evidencia: keys nao identity explicitas com +`IsKey().SetGeneratedOption(DatabaseGeneratedOption.None)` participam do INSERT +e nao entram no SET do UPDATE. Coberto por +`NonIdentityKeyShouldParticipateInInsertAndStayOutOfUpdateSet` e +`CompositeNonIdentityKeyShouldParticipateInInsertAndStayOutOfUpdateSet`. + +Observacao de compatibilidade: `IsKey()` sem `SetGeneratedOption(None)` continua +identity operacional no key resolver Dommel para preservar maps antigos. + +### Evidencia historica - `src/Dapper.FluentMap.Dommel/Mapping/DommelPropertyMap.cs` - `src/Dapper.FluentMap.Dommel/Resolvers/DommelKeyPropertyResolver.cs` @@ -182,12 +196,16 @@ Computed deve ser uma semantica de escrita, nao sinonimo de ignore. A decisao deve dizer que computed normalmente e `Read=yes`, `Insert=no`, `Update=no`, `Generated=yes`, `Computed=yes`. -### Decisao +### Status apos Prompt 8.4 -Resolver por nova arquitetura e adicionar regression test historico em prompt -posterior. +Resolved. -### Evidencia +Evidencia: `Computed()` e `SetGeneratedOption(DatabaseGeneratedOption.Computed)` +sao traduzidos para omissao de INSERT e UPDATE, preservando leitura. Coberto por +`DommelPersistenceIntegrationTests.InsertUpdateAndSelectShouldHonorPropertyPersistenceMetadata` +com coluna SQLite generated. + +### Evidencia historica - `src/Dapper.FluentMap.Dommel/Resolvers/DommelPropertyResolver.cs` - Dommel `Insert.cs` e `Update.cs` filtram `IsGenerated`: @@ -236,11 +254,16 @@ insert" ainda e reproduzivel como ausencia de semantica explicita no core. Motiva uma semantica conceitual do tipo `Read=yes`, `Insert=no`, `Update=yes` ou `Update=no`, conforme decisao explicita por API futura. -### Decisao +### Status apos Prompt 8.4 -Resolver por nova arquitetura. +Resolved. -### Evidencia +Evidencia: `DatabaseDefaultOnInsert()` omite a coluna do INSERT, permite leitura +posterior e preserva UPDATE por default. A regressao usa coluna +`created_at TEXT DEFAULT CURRENT_TIMESTAMP` em +`DommelPersistenceIntegrationTests.InsertUpdateAndSelectShouldHonorPropertyPersistenceMetadata`. + +### Evidencia historica - `src/Dapper.FluentMap/Compatibility/DapperIgnoredMemberMap.cs` - `test/Dapper.FluentMap.GeneratedRegistration.Tests/GeneratedRegistrationIntegrationTests.cs` diff --git a/.sdd/etapa-8/05-dommel-persistence-behavior.md b/.sdd/etapa-8/05-dommel-persistence-behavior.md new file mode 100644 index 0000000..4ce2200 --- /dev/null +++ b/.sdd/etapa-8/05-dommel-persistence-behavior.md @@ -0,0 +1,105 @@ +# Etapa 8 - Dommel Persistence Behavior + +Status: implementado no Prompt 8.4. + +## Objetivo + +Fazer `Dapper.FluentMap.Dommel` consumir a metadata de persistencia do core para +os comandos gerados pelo Dommel, mantendo o core fora da geracao de SQL. + +Dommel 3.5.3 expoe `ColumnPropertyInfo.IsGenerated` como filtro unico usado por +`INSERT` e `UPDATE`. Para preservar a semantica separada de `Insert` e `Update`, +a integracao usa duas traducoes: + +- `DommelPropertyResolver` traduz `ParticipatesInUpdate=false` para + `ColumnPropertyInfo.IsGenerated=true`, porque o `UPDATE` do Dommel filtra por + esse contrato. +- `DommelPersistenceSqlBuilder` envolve os SQL builders padrao e recompõe as + colunas de `INSERT` a partir de `ParticipatesInInsert`, ignorando o filtro + unico recebido do Dommel quando existe map FluentMap registrado. + +O core continua sem SQL provider-specific. A diferenca de SQL fica no pacote +Dommel e delega quoting, parametros e formato de insert aos builders do Dommel. + +## Matriz de comportamento + +| Behavior | SELECT | INSERT | UPDATE | +| --- | --- | --- | --- | +| Normal | Sim | Sim | Sim | +| Ignore | Nao | Nao | Nao | +| ReadOnly | Sim | Nao | Nao | +| InsertExcluded | Sim | Nao | Sim | +| UpdateExcluded | Sim | Sim | Nao | +| Generated | Sim | Conforme subtipo gerado | Conforme subtipo gerado | +| Computed | Sim | Nao | Nao | +| NonIdentityKey | Sim | Sim | WHERE only; nao entra no SET | + +`DatabaseDefaultOnInsert()` e tratado como `InsertExcluded` com metadata +`Generated` e `DefaultOnInsert`: a coluna e omitida do `INSERT`, continua sendo +lida em `SELECT` e participa do `UPDATE` por default. Quando combinado com +`ExcludeFromUpdate()`, passa a ser read-only depois do insert. + +`Identity` e uma key gerada pelo banco: participa de leitura e do `WHERE` quando +usada como key, mas nao entra em `INSERT` nem no `SET` de `UPDATE`. + +## Dommel 3.5.3 + +O contrato efetivamente usado nesta etapa foi o pacote `Dommel` 3.5.3 referenciado +por `src/Dapper.FluentMap.Dommel/Dapper.FluentMap.Dommel.csproj`. + +Observacoes relevantes: + +- `IPropertyResolver.ResolveProperties(Type)` retorna `ColumnPropertyInfo`. +- `ColumnPropertyInfo.IsGenerated` e derivado de `GeneratedOption != None`. +- `BuildInsertQuery` usa propriedades nao geradas e depois chama + `ISqlBuilder.BuildInsert(...)`. +- `BuildUpdateQuery` usa propriedades nao geradas no `SET` e key properties no + `WHERE`. +- Nao ha hook publico separado para `INSERT` versus `UPDATE`. + +## Decisoes implementadas + +- `ReadOnly()` e `Computed()` continuam materializaveis em SELECT e sao omitidos + de INSERT/UPDATE. +- `ExcludeFromInsert()` e `DatabaseDefaultOnInsert()` sao omitidos de INSERT e + continuam no UPDATE. +- `ExcludeFromUpdate()` continua no INSERT e e omitido do UPDATE. +- Key nao identity declarada com `IsKey().SetGeneratedOption(None)` participa do + INSERT e nao entra no SET do UPDATE. +- Composite key nao identity segue a mesma regra para todos os componentes. +- `Ignore()` continua fora de SELECT/INSERT/UPDATE para os caminhos Dommel. +- Mappings herdados via `IncludeBase()` sao considerados pelos resolvers + Dommel para nome de coluna, insert e update. + +## Compatibilidade + +Mappings sem as novas opcoes preservam o comportamento historico esperado. +`IsKey()` sem `SetGeneratedOption(None)` continua sendo tratado como identity +operacional no key resolver Dommel para compatibilidade com maps antigos. + +Consumidores que registrarem SQL builders customizados depois de `ForDommel()` +substituem o wrapper instalado pela integracao e ficam responsaveis por honrar a +metadata de insert. Os builders padrao de SQL Server, SQL Server CE, SQLite, +PostgreSQL e MySQL sao envolvidos automaticamente. + +## Cobertura + +`DommelPersistenceIntegrationTests` executa operacoes reais com SQLite +in-memory cobrindo: + +- propriedade normal em INSERT e UPDATE; +- ignored; +- read-only; +- exclude insert; +- exclude update; +- computed/generated; +- identity; +- database default equivalente a `created_at DEFAULT CURRENT_TIMESTAMP`; +- mapping herdado; +- key nao identity; +- composite key nao identity; +- operacoes repetidas em entidades diferentes. + +SQLite foi usado para validar semantica geral de omissao de colunas e defaults +de banco. Nenhuma conclusao provider-specific de SQL Server ou PostgreSQL foi +inferida alem do contrato de builders do Dommel. diff --git a/.sdd/etapa-8/STATUS.md b/.sdd/etapa-8/STATUS.md index 9b5109f..9041839 100644 --- a/.sdd/etapa-8/STATUS.md +++ b/.sdd/etapa-8/STATUS.md @@ -101,6 +101,27 @@ execucao de CRUD ao core. `QueryMappedSimpleRuntimeFallback` 4.435 ms / 361.53 KB. BenchmarkDotNet alertou que a iteracao unica e curta demais para conclusao estatistica; como smoke, nao indicou regressao evidente do hot path. +- Criado `.sdd/etapa-8/05-dommel-persistence-behavior.md`. +- Adaptado `Dapper.FluentMap.Dommel` para consumir metadata de persistencia em + INSERT e UPDATE. +- Adicionado wrapper de `ISqlBuilder` para recompor colunas de INSERT com base + em `ParticipatesInInsert`. +- Alterado resolver de propriedades Dommel para usar `ParticipatesInUpdate` no + filtro operacional de UPDATE. +- Resolvido consumo de mappings herdados por resolvers Dommel. +- Adicionados testes reais SQLite para normal, ignored, read-only, + insert-excluded, update-excluded, computed, identity, database default, + mapping herdado, key nao identity, composite key nao identity e operacoes + repetidas em entidades diferentes. +- Executado `dotnet build .\test\Dapper.FluentMap.Dommel.Tests\Dapper.FluentMap.Dommel.Tests.csproj --configuration Release`: + sucesso, 0 warnings, 0 errors. +- Executado `dotnet test .\test\Dapper.FluentMap.Dommel.Tests\Dapper.FluentMap.Dommel.Tests.csproj --configuration Release --no-build`: + sucesso, 17 testes aprovados. +- Executado `dotnet restore .\Dapper.FluentMap.sln`: sucesso. +- Executado `dotnet build .\Dapper.FluentMap.sln --configuration Release --no-restore`: + sucesso, 0 warnings, 0 errors. +- Executado `dotnet test .\Dapper.FluentMap.sln --configuration Release --no-build`: + sucesso, 281 testes aprovados. ## Em andamento @@ -108,13 +129,8 @@ Nenhum apos a validacao final deste prompt. ## Proximos passos -1. Adaptar FluentMap.Dommel para consumo operacional completo de metadata - `Insert`/`Update` quando houver contrato seguro para nao confundir update com - generated. -2. Criar suite de regressao historica para #94, #122, #123, #130, #114, #126 e - #133. -3. Atualizar analyzers/source generator para reconhecer a nova DSL. -4. Fazer hardening de cache, profiles, generated materializers e Dommel SQL real. +1. Atualizar analyzers/source generator para reconhecer a nova DSL. +2. Fazer hardening de cache, profiles, generated materializers e Dommel SQL real. ## Decisoes relevantes @@ -132,12 +148,14 @@ Nenhum apos a validacao final deste prompt. ## Issues historicas -- #94 ReadOnly Fields: resolver por nova arquitetura. -- #122 Insert issue when key column is not identity: parcialmente corrigida, - manter regressao e separar key/identity. -- #123 Computed property used in insert/update: provavel correcao via resolvers - atuais, ainda requer regressao de SQL real. -- #130 Default value do banco vs `Ignore()`: resolver por nova arquitetura. +- #94 ReadOnly Fields: resolved no Dommel, com regressao SQLite de INSERT, + UPDATE e SELECT. +- #122 Insert issue when key column is not identity: regression covered para key + nao identity explicita e composite key. +- #123 Computed property used in insert/update: resolved no Dommel, com coluna + generated real em SQLite. +- #130 Default value do banco vs `Ignore()`: resolved com + `DatabaseDefaultOnInsert()` e `created_at DEFAULT CURRENT_TIMESTAMP`. - #114 conflito entre property e membros do tipo: ja resolvido, preservar. - #126 nested properties com mesmo terminal: ja resolvido no core/generated, preservar. @@ -159,10 +177,10 @@ Nenhum apos a validacao final deste prompt. - `Generated` e amplo demais para representar sozinho default, computed e identity. - Dommel ainda tem uma ponte de compatibilidade: `IsKey()` sem - `SetGeneratedOption(None)` continua identity operacional nos resolvers, embora - a metadata de core diferencie key de identity. -- `ExcludeFromInsert()` isolado e `DatabaseDefaultOnInsert()` com update ativo - ainda nao podem ser traduzidos fielmente para `ColumnPropertyInfo.IsGenerated`. + `SetGeneratedOption(None)` continua identity operacional no key resolver, + embora a metadata de core diferencie key de identity. +- SQL builders customizados registrados depois de `ForDommel()` substituem o + wrapper padrao e precisam honrar `ParticipatesInInsert` por conta propria. ## Arquivos importantes @@ -185,7 +203,8 @@ Nenhum apos a validacao final deste prompt. - `src/Dapper.FluentMap.Generators/MappingRegistrationGenerator.cs` - `src/Dapper.FluentMap.Analyzers/FluentMapConfigurationAnalyzer.cs` - `.sdd/etapa-8/04-read-semantics.md` +- `.sdd/etapa-8/05-dommel-persistence-behavior.md` ## Ultimo prompt executado -Ultimo prompt executado: 8.3 +Ultimo prompt executado: 8.4 diff --git a/README.md b/README.md index 3df9b4a..d2521e7 100644 --- a/README.md +++ b/README.md @@ -411,6 +411,14 @@ FluentMapper.Initialize(config => }); ``` +Dommel honors FluentMap persistence metadata for generated INSERT and UPDATE +commands. `ReadOnly()` and `Computed()` are selected but not written, +`DatabaseDefaultOnInsert()` and `ExcludeFromInsert()` are omitted from INSERT +while remaining updateable, and `ExcludeFromUpdate()` remains insertable but is +not written by UPDATE. Assigned keys can be configured with +`IsKey().SetGeneratedOption(DatabaseGeneratedOption.None)` so they participate in +INSERT instead of being treated as database-generated identities. + ## Current Limitations - FluentMap configuration is process-wide. Configure at startup and avoid changing mappings while queries are running. @@ -851,6 +859,14 @@ FluentMapper.Initialize(config => }); ``` +A integração Dommel respeita a metadata de persistência em comandos INSERT e +UPDATE gerados. `ReadOnly()` e `Computed()` são selecionados, mas não escritos; +`DatabaseDefaultOnInsert()` e `ExcludeFromInsert()` são omitidos do INSERT e +continuam atualizáveis; `ExcludeFromUpdate()` continua inserível, mas não é +escrito pelo UPDATE. Chaves atribuídas pela aplicação podem ser configuradas com +`IsKey().SetGeneratedOption(DatabaseGeneratedOption.None)` para participar do +INSERT em vez de serem tratadas como identities geradas pelo banco. + ## 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. diff --git a/src/Dapper.FluentMap.Dommel/FluentMapConfigurationExtensions.cs b/src/Dapper.FluentMap.Dommel/FluentMapConfigurationExtensions.cs index 80c768c..566fb9d 100644 --- a/src/Dapper.FluentMap.Dommel/FluentMapConfigurationExtensions.cs +++ b/src/Dapper.FluentMap.Dommel/FluentMapConfigurationExtensions.cs @@ -20,6 +20,7 @@ public static FluentMapConfiguration ForDommel(this FluentMapConfiguration confi DommelMapper.SetKeyPropertyResolver(new DommelKeyPropertyResolver()); DommelMapper.SetTableNameResolver(new DommelTableNameResolver()); DommelMapper.SetPropertyResolver(new DommelPropertyResolver()); + DommelPersistenceSqlBuilder.RegisterDefaults(); return config; } } diff --git a/src/Dapper.FluentMap.Dommel/Mapping/DommelPropertyMap.cs b/src/Dapper.FluentMap.Dommel/Mapping/DommelPropertyMap.cs index 4e17c90..d3df697 100644 --- a/src/Dapper.FluentMap.Dommel/Mapping/DommelPropertyMap.cs +++ b/src/Dapper.FluentMap.Dommel/Mapping/DommelPropertyMap.cs @@ -33,25 +33,33 @@ public DommelPropertyMap(PropertyInfo info) : base(info) /// public DatabaseGeneratedOption? GeneratedOption { get; set; } - internal DatabaseGeneratedOption EffectiveGeneratedOption + internal DatabaseGeneratedOption EffectiveUpdateGeneratedOption { get { - if (GeneratedOption.HasValue) - { - return GeneratedOption.Value; - } - if (Persistence.IsIdentity) { return DatabaseGeneratedOption.Identity; } - if (!Persistence.ParticipatesInInsert && !Persistence.ParticipatesInUpdate) + if (!Persistence.ParticipatesInUpdate) { return DatabaseGeneratedOption.Computed; } + return DatabaseGeneratedOption.None; + } + } + + internal DatabaseGeneratedOption EffectiveKeyGeneratedOption + { + get + { + if (GeneratedOption.HasValue) + { + return GeneratedOption.Value; + } + return Key ? DatabaseGeneratedOption.Identity : DatabaseGeneratedOption.None; } } diff --git a/src/Dapper.FluentMap.Dommel/Resolvers/DommelColumnNameResolver.cs b/src/Dapper.FluentMap.Dommel/Resolvers/DommelColumnNameResolver.cs index 396b9d9..f8cabcd 100644 --- a/src/Dapper.FluentMap.Dommel/Resolvers/DommelColumnNameResolver.cs +++ b/src/Dapper.FluentMap.Dommel/Resolvers/DommelColumnNameResolver.cs @@ -28,7 +28,10 @@ public string ResolveColumnName(PropertyInfo propertyInfo) var mapping = entityMap as IDommelEntityMap; if (mapping != null) { - var propertyMaps = entityMap.PropertyMaps.Where(m => m.PropertyInfo.Name == propertyInfo.Name).ToList(); + var propertyMaps = DommelPersistenceMetadata + .ResolvePropertyMaps(propertyInfo.ReflectedType ?? propertyInfo.DeclaringType, entityMap) + .Where(m => m.PropertyInfo.Name == propertyInfo.Name) + .ToList(); if (propertyMaps.Count == 1) { return propertyMaps[0].ColumnName; diff --git a/src/Dapper.FluentMap.Dommel/Resolvers/DommelKeyPropertyResolver.cs b/src/Dapper.FluentMap.Dommel/Resolvers/DommelKeyPropertyResolver.cs index d0063ea..75eb2e7 100644 --- a/src/Dapper.FluentMap.Dommel/Resolvers/DommelKeyPropertyResolver.cs +++ b/src/Dapper.FluentMap.Dommel/Resolvers/DommelKeyPropertyResolver.cs @@ -27,10 +27,10 @@ public ColumnPropertyInfo[] ResolveKeyProperties(Type type) var mapping = entityMap as IDommelEntityMap; if (mapping != null) { - var allPropertyMaps = entityMap.PropertyMaps.OfType(); + var allPropertyMaps = DommelPersistenceMetadata.ResolvePropertyMaps(type, entityMap).OfType().ToList(); var keyPropertyInfos = allPropertyMaps .Where(e => e.Key) - .Select(x => new ColumnPropertyInfo(x.PropertyInfo, x.EffectiveGeneratedOption)) + .Select(x => new ColumnPropertyInfo(x.PropertyInfo, x.EffectiveKeyGeneratedOption)) .ToArray(); // Now make sure there aren't any missing key properties that weren't explicitly defined in the mapping. diff --git a/src/Dapper.FluentMap.Dommel/Resolvers/DommelPersistenceMetadata.cs b/src/Dapper.FluentMap.Dommel/Resolvers/DommelPersistenceMetadata.cs new file mode 100644 index 0000000..4982fbb --- /dev/null +++ b/src/Dapper.FluentMap.Dommel/Resolvers/DommelPersistenceMetadata.cs @@ -0,0 +1,122 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Reflection; +using Dapper.FluentMap.Mapping; + +namespace Dapper.FluentMap.Dommel.Resolvers +{ + internal static class DommelPersistenceMetadata + { + internal static bool TryGetPersistence(PropertyInfo property, out PropertyPersistenceMetadata persistence) + { + persistence = null; + + IEntityMap entityMap; + if (!TryGetEntityMap(property, out entityMap)) + { + return false; + } + + var propertyMap = ResolvePropertyMap(property.ReflectedType ?? property.DeclaringType, entityMap, property.Name); + if (propertyMap == null) + { + persistence = PropertyPersistenceMetadata.Default; + return true; + } + + var mapWithPersistence = propertyMap as IPropertyMapWithPersistenceMetadata; + persistence = mapWithPersistence == null + ? (propertyMap.Ignored ? PropertyPersistenceMetadata.Ignored : PropertyPersistenceMetadata.Default) + : mapWithPersistence.Persistence; + return true; + } + + internal static IEnumerable ResolveInsertProperties(Type type) + { + IEntityMap entityMap; + if (!FluentMapper.EntityMaps.TryGetValue(type, out entityMap)) + { + return null; + } + + var propertyResolver = new DommelPropertyResolver(); + return propertyResolver + .ResolveProperties(type) + .Where(property => + { + PropertyPersistenceMetadata persistence; + return !TryGetPersistence(property.Property, out persistence) || + persistence.ParticipatesInInsert; + }) + .Select(property => property.Property); + } + + internal static IPropertyMap ResolvePropertyMap(Type type, IEntityMap entityMap, string propertyName) + { + return ResolvePropertyMaps(type, entityMap).FirstOrDefault(map => map.PropertyInfo.Name == propertyName); + } + + internal static IList ResolvePropertyMaps(Type type, IEntityMap entityMap) + { + var propertyMaps = new List(); + AddPropertyMapsWithOverride(propertyMaps, entityMap.PropertyMaps); + + foreach (var baseType in GetIncludedBaseTypes(entityMap)) + { + IEntityMap baseMap; + if (FluentMapper.EntityMaps.TryGetValue(baseType, out baseMap)) + { + AddPropertyMapsWithOverride(propertyMaps, ResolvePropertyMaps(baseType, baseMap)); + } + } + + return propertyMaps; + } + + private static bool TryGetEntityMap(PropertyInfo property, out IEntityMap entityMap) + { + entityMap = null; + + var reflectedType = property.ReflectedType; + if (reflectedType != null && FluentMapper.EntityMaps.TryGetValue(reflectedType, out entityMap)) + { + return true; + } + + var declaringType = property.DeclaringType; + return declaringType != null && FluentMapper.EntityMaps.TryGetValue(declaringType, out entityMap); + } + + private static void AddPropertyMapsWithOverride(IList target, IEnumerable propertyMaps) + { + foreach (var propertyMap in propertyMaps) + { + if (target.Any(existing => existing.PropertyInfo.Name == propertyMap.PropertyInfo.Name)) + { + continue; + } + + target.Add(propertyMap); + } + } + + private static IEnumerable GetIncludedBaseTypes(IEntityMap entityMap) + { + var includedBaseInterface = entityMap + .GetType() + .GetInterfaces() + .FirstOrDefault(type => type.FullName == "Dapper.FluentMap.Mapping.IEntityMapWithIncludedBaseTypes"); + + if (includedBaseInterface == null) + { + return new Type[0]; + } + + var property = includedBaseInterface.GetProperty("IncludedBaseTypes"); + return property == null + ? new Type[0] + : ((IEnumerable)property.GetValue(entityMap, null)).ToArray(); + } + } +} diff --git a/src/Dapper.FluentMap.Dommel/Resolvers/DommelPersistenceSqlBuilder.cs b/src/Dapper.FluentMap.Dommel/Resolvers/DommelPersistenceSqlBuilder.cs new file mode 100644 index 0000000..137c403 --- /dev/null +++ b/src/Dapper.FluentMap.Dommel/Resolvers/DommelPersistenceSqlBuilder.cs @@ -0,0 +1,64 @@ +using System; +using System.Linq; +using Dommel; + +namespace Dapper.FluentMap.Dommel.Resolvers +{ + internal sealed class DommelPersistenceSqlBuilder : ISqlBuilder + { + private readonly ISqlBuilder inner; + + private DommelPersistenceSqlBuilder(ISqlBuilder inner) + { + this.inner = inner ?? throw new ArgumentNullException(nameof(inner)); + } + + internal static void RegisterDefaults() + { + DommelMapper.AddSqlBuilder("sqlconnection", new DommelPersistenceSqlBuilder(new SqlServerSqlBuilder())); + DommelMapper.AddSqlBuilder("sqlceconnection", new DommelPersistenceSqlBuilder(new SqlServerCeSqlBuilder())); + DommelMapper.AddSqlBuilder("sqliteconnection", new DommelPersistenceSqlBuilder(new SqliteSqlBuilder())); + DommelMapper.AddSqlBuilder("npgsqlconnection", new DommelPersistenceSqlBuilder(new PostgresSqlBuilder())); + DommelMapper.AddSqlBuilder("mysqlconnection", new DommelPersistenceSqlBuilder(new MySqlSqlBuilder())); + } + + public string PrefixParameter(string paramName) + { + return inner.PrefixParameter(paramName); + } + + public string BuildInsert(Type type, string tableName, string[] columnNames, string[] paramNames) + { + var insertProperties = DommelPersistenceMetadata.ResolveInsertProperties(type); + if (insertProperties == null) + { + return inner.BuildInsert(type, tableName, columnNames, paramNames); + } + + var properties = insertProperties.ToArray(); + var persistenceColumnNames = properties.Select(property => global::Dommel.Resolvers.Column(property, this, false)).ToArray(); + var persistenceParamNames = properties.Select(property => PrefixParameter(property.Name)).ToArray(); + return inner.BuildInsert(type, tableName, persistenceColumnNames, persistenceParamNames); + } + + public string BuildPaging(string orderBy, int pageNumber, int pageSize) + { + return inner.BuildPaging(orderBy, pageNumber, pageSize); + } + + public string QuoteIdentifier(string identifier) + { + return inner.QuoteIdentifier(identifier); + } + + public string LimitClause(int count) + { + return inner.LimitClause(count); + } + + public string LikeExpression(string columnName, string parameterName) + { + return inner.LikeExpression(columnName, parameterName); + } + } +} diff --git a/src/Dapper.FluentMap.Dommel/Resolvers/DommelPropertyResolver.cs b/src/Dapper.FluentMap.Dommel/Resolvers/DommelPropertyResolver.cs index 5387d47..17d43cf 100644 --- a/src/Dapper.FluentMap.Dommel/Resolvers/DommelPropertyResolver.cs +++ b/src/Dapper.FluentMap.Dommel/Resolvers/DommelPropertyResolver.cs @@ -40,13 +40,13 @@ public override IEnumerable ResolveProperties(Type type) foreach (var property in FilterComplexTypes(type.GetProperties())) { // Determine whether the property should be ignored. - var propertyMap = entityMap.PropertyMaps.FirstOrDefault(p => p.PropertyInfo.Name == property.Name); + var propertyMap = DommelPersistenceMetadata.ResolvePropertyMap(type, entityMap, property.Name); if (propertyMap == null || !propertyMap.Ignored) { var dommelPropertyMap = propertyMap as DommelPropertyMap; if (dommelPropertyMap != null) { - yield return new ColumnPropertyInfo(property, dommelPropertyMap.EffectiveGeneratedOption); + yield return new ColumnPropertyInfo(property, dommelPropertyMap.EffectiveUpdateGeneratedOption); } else { @@ -74,7 +74,7 @@ private static DatabaseGeneratedOption ResolveGeneratedOption(PropertyPersistenc return DatabaseGeneratedOption.Identity; } - if (!persistence.ParticipatesInInsert && !persistence.ParticipatesInUpdate) + if (!persistence.ParticipatesInUpdate) { return DatabaseGeneratedOption.Computed; } diff --git a/test/Dapper.FluentMap.Dommel.Tests/Dapper.FluentMap.Dommel.Tests.csproj b/test/Dapper.FluentMap.Dommel.Tests/Dapper.FluentMap.Dommel.Tests.csproj index 6b2b11d..3ef78eb 100644 --- a/test/Dapper.FluentMap.Dommel.Tests/Dapper.FluentMap.Dommel.Tests.csproj +++ b/test/Dapper.FluentMap.Dommel.Tests/Dapper.FluentMap.Dommel.Tests.csproj @@ -5,7 +5,9 @@ + + runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/test/Dapper.FluentMap.Dommel.Tests/DommelPersistenceIntegrationTests.cs b/test/Dapper.FluentMap.Dommel.Tests/DommelPersistenceIntegrationTests.cs new file mode 100644 index 0000000..fee32f8 --- /dev/null +++ b/test/Dapper.FluentMap.Dommel.Tests/DommelPersistenceIntegrationTests.cs @@ -0,0 +1,471 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations.Schema; +using System.Linq; +using Dapper; +using Dapper.FluentMap.Dommel.Mapping; +using Dommel; +using Microsoft.Data.Sqlite; +using Xunit; + +namespace Dapper.FluentMap.Dommel.Tests +{ + public class DommelPersistenceIntegrationTests + { + [Fact] + public void InsertUpdateAndSelectShouldHonorPropertyPersistenceMetadata() + { + PreTest(); + SQLitePCL.Batteries_V2.Init(); + + FluentMapper.Initialize(config => + { + config.AddMap(new PersistenceBaseEntityMap()); + config.AddMap(new PersistenceEntityMap()); + config.ForDommel(); + }); + + using (var connection = OpenConnection()) + { + connection.Execute(@" +CREATE TABLE persistence_entities ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + normal TEXT NOT NULL, + ignored TEXT DEFAULT 'ignored-default', + read_only TEXT DEFAULT 'read-only-default', + insert_excluded TEXT DEFAULT 'insert-excluded-default', + update_excluded TEXT DEFAULT 'update-excluded-default', + default_value TEXT DEFAULT 'default-value-default', + created_at TEXT DEFAULT CURRENT_TIMESTAMP, + inherited_value TEXT DEFAULT 'inherited-default', + computed TEXT GENERATED ALWAYS AS (normal || '-computed') STORED +);"); + + var logs = CaptureDommelLogs(); + try + { + var entity = new PersistenceEntity + { + Normal = "normal-insert", + Ignored = "ignored-insert", + ReadOnly = "read-only-insert", + InsertExcluded = "insert-excluded-insert", + UpdateExcluded = "update-excluded-insert", + DefaultValue = "default-value-insert", + CreatedAt = new DateTime(2000, 1, 1), + InheritedValue = "inherited-insert", + Computed = "computed-insert" + }; + + var id = Convert.ToInt32(connection.Insert(entity)); + var inserted = SelectPersistenceRow(connection, id); + + Assert.Equal("normal-insert", inserted.Normal); + Assert.Equal("ignored-default", inserted.Ignored); + Assert.Equal("read-only-default", inserted.ReadOnly); + Assert.Equal("insert-excluded-default", inserted.InsertExcluded); + Assert.Equal("update-excluded-insert", inserted.UpdateExcluded); + Assert.Equal("default-value-default", inserted.DefaultValue); + Assert.NotEqual(new DateTime(2000, 1, 1), inserted.CreatedAt); + Assert.Equal("inherited-default", inserted.InheritedValue); + Assert.Equal("normal-insert-computed", inserted.Computed); + + var insertSql = logs.Last(log => log.IndexOf("insert into", StringComparison.OrdinalIgnoreCase) >= 0); + AssertSqlContains(insertSql, "normal", "update_excluded"); + AssertSqlDoesNotContain(insertSql, "\"id\"", "ignored", "read_only", "insert_excluded", "default_value", "created_at", "inherited_value", "computed"); + + entity.Id = id; + entity.Normal = "normal-update"; + entity.Ignored = "ignored-update"; + entity.ReadOnly = "read-only-update"; + entity.InsertExcluded = "insert-excluded-update"; + entity.UpdateExcluded = "update-excluded-update"; + entity.DefaultValue = "default-value-update"; + entity.CreatedAt = new DateTime(2001, 2, 3, 4, 5, 6); + entity.InheritedValue = "inherited-update"; + entity.Computed = "computed-update"; + + Assert.True(connection.Update(entity)); + + var updated = SelectPersistenceRow(connection, id); + Assert.Equal("normal-update", updated.Normal); + Assert.Equal("ignored-default", updated.Ignored); + Assert.Equal("read-only-default", updated.ReadOnly); + Assert.Equal("insert-excluded-update", updated.InsertExcluded); + Assert.Equal("update-excluded-insert", updated.UpdateExcluded); + Assert.Equal("default-value-update", updated.DefaultValue); + Assert.Equal(new DateTime(2001, 2, 3, 4, 5, 6), updated.CreatedAt); + Assert.Equal("inherited-update", updated.InheritedValue); + Assert.Equal("normal-update-computed", updated.Computed); + + var updateSql = logs.Last(log => log.IndexOf("update ", StringComparison.OrdinalIgnoreCase) >= 0); + AssertSqlContains(updateSql, "normal", "insert_excluded", "default_value", "created_at", "inherited_value"); + AssertSqlDoesNotContain(updateSql, "ignored", "read_only", "update_excluded", "computed"); + Assert.DoesNotContain("set \"id\"", updateSql, StringComparison.OrdinalIgnoreCase); + + var loaded = connection.Get(id); + Assert.Equal("read-only-default", loaded.ReadOnly); + Assert.Equal("insert-excluded-update", loaded.InsertExcluded); + Assert.Equal("default-value-update", loaded.DefaultValue); + Assert.Equal(new DateTime(2001, 2, 3, 4, 5, 6), loaded.CreatedAt); + Assert.Equal("normal-update-computed", loaded.Computed); + Assert.Null(loaded.Ignored); + } + finally + { + DommelMapper.LogReceived = null; + } + } + } + + [Fact] + public void NonIdentityKeyShouldParticipateInInsertAndStayOutOfUpdateSet() + { + PreTest(); + SQLitePCL.Batteries_V2.Init(); + + FluentMapper.Initialize(config => + { + config.AddMap(new AssignedKeyEntityMap()); + config.ForDommel(); + }); + + using (var connection = OpenConnection()) + { + connection.Execute(@" +CREATE TABLE assigned_key_entities ( + code TEXT PRIMARY KEY, + name TEXT NOT NULL, + update_excluded TEXT +);"); + + var logs = CaptureDommelLogs(); + try + { + var entity = new AssignedKeyEntity + { + Code = "A-001", + Name = "inserted", + UpdateExcluded = "insert-write" + }; + + connection.Insert(entity); + + var inserted = connection.QuerySingle( + "SELECT code AS Code, name AS Name, update_excluded AS UpdateExcluded FROM assigned_key_entities WHERE code = 'A-001';"); + Assert.Equal("A-001", inserted.Code); + Assert.Equal("inserted", inserted.Name); + Assert.Equal("insert-write", inserted.UpdateExcluded); + + var insertSql = logs.Last(log => log.IndexOf("insert into", StringComparison.OrdinalIgnoreCase) >= 0); + AssertSqlContains(insertSql, "code", "name", "update_excluded"); + + entity.Name = "updated"; + entity.UpdateExcluded = "update-write"; + Assert.True(connection.Update(entity)); + + var updated = connection.QuerySingle( + "SELECT code AS Code, name AS Name, update_excluded AS UpdateExcluded FROM assigned_key_entities WHERE code = 'A-001';"); + Assert.Equal("A-001", updated.Code); + Assert.Equal("updated", updated.Name); + Assert.Equal("insert-write", updated.UpdateExcluded); + + var updateSql = logs.Last(log => log.IndexOf("update ", StringComparison.OrdinalIgnoreCase) >= 0); + AssertSqlContains(updateSql, "name", "where", "code"); + Assert.DoesNotContain("set \"code\"", updateSql, StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain("update_excluded", updateSql, StringComparison.OrdinalIgnoreCase); + } + finally + { + DommelMapper.LogReceived = null; + } + } + } + + [Fact] + public void CompositeNonIdentityKeyShouldParticipateInInsertAndStayOutOfUpdateSet() + { + PreTest(); + SQLitePCL.Batteries_V2.Init(); + + FluentMapper.Initialize(config => + { + config.AddMap(new CompositePersistenceEntityMap()); + config.ForDommel(); + }); + + using (var connection = OpenConnection()) + { + connection.Execute(@" +CREATE TABLE composite_persistence_entities ( + key_part_one INTEGER NOT NULL, + key_part_two INTEGER NOT NULL, + value TEXT NOT NULL, + PRIMARY KEY (key_part_one, key_part_two) +);"); + + var logs = CaptureDommelLogs(); + try + { + var entity = new CompositePersistenceEntity + { + KeyPartOne = 10, + KeyPartTwo = 20, + Value = "inserted" + }; + + connection.Insert(entity); + + var inserted = connection.QuerySingle( + "SELECT value FROM composite_persistence_entities WHERE key_part_one = 10 AND key_part_two = 20;"); + Assert.Equal("inserted", inserted); + + var insertSql = logs.Last(log => log.IndexOf("insert into", StringComparison.OrdinalIgnoreCase) >= 0); + AssertSqlContains(insertSql, "key_part_one", "key_part_two", "value"); + + entity.Value = "updated"; + Assert.True(connection.Update(entity)); + + var updated = connection.QuerySingle( + "SELECT value FROM composite_persistence_entities WHERE key_part_one = 10 AND key_part_two = 20;"); + Assert.Equal("updated", updated); + + var updateSql = logs.Last(log => log.IndexOf("update ", StringComparison.OrdinalIgnoreCase) >= 0); + AssertSqlContains(updateSql, "value", "where", "key_part_one", "key_part_two"); + Assert.DoesNotContain("set \"key_part_one\"", updateSql, StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain("set \"key_part_two\"", updateSql, StringComparison.OrdinalIgnoreCase); + } + finally + { + DommelMapper.LogReceived = null; + } + } + } + + [Fact] + public void RepeatedOperationsForDifferentEntitiesShouldKeepIndependentPersistenceMetadata() + { + PreTest(); + SQLitePCL.Batteries_V2.Init(); + + FluentMapper.Initialize(config => + { + config.AddMap(new AssignedKeyEntityMap()); + config.AddMap(new CompositePersistenceEntityMap()); + config.ForDommel(); + }); + + using (var connection = OpenConnection()) + { + connection.Execute(@" +CREATE TABLE assigned_key_entities ( + code TEXT PRIMARY KEY, + name TEXT NOT NULL, + update_excluded TEXT +); +CREATE TABLE composite_persistence_entities ( + key_part_one INTEGER NOT NULL, + key_part_two INTEGER NOT NULL, + value TEXT NOT NULL, + PRIMARY KEY (key_part_one, key_part_two) +);"); + + connection.Insert(new AssignedKeyEntity + { + Code = "A-002", + Name = "assigned", + UpdateExcluded = "assigned-excluded" + }); + + connection.Insert(new CompositePersistenceEntity + { + KeyPartOne = 30, + KeyPartTwo = 40, + Value = "composite" + }); + + Assert.Equal("assigned", connection.QuerySingle("SELECT name FROM assigned_key_entities WHERE code = 'A-002';")); + Assert.Equal("composite", connection.QuerySingle("SELECT value FROM composite_persistence_entities WHERE key_part_one = 30 AND key_part_two = 40;")); + } + } + + private static void PreTest() + { + FluentMapper.EntityMaps.Clear(); + FluentMapper.TypeConventions.Clear(); + DommelMapper.LogReceived = null; + } + + private static List CaptureDommelLogs() + { + var logs = new List(); + DommelMapper.LogReceived = logs.Add; + return logs; + } + + private static SqliteConnection OpenConnection() + { + var connection = new SqliteConnection("Data Source=:memory:"); + connection.Open(); + return connection; + } + + private static PersistenceRow SelectPersistenceRow(SqliteConnection connection, int id) + { + return connection.QuerySingle(@" +SELECT + id AS Id, + normal AS Normal, + ignored AS Ignored, + read_only AS ReadOnly, + insert_excluded AS InsertExcluded, + update_excluded AS UpdateExcluded, + default_value AS DefaultValue, + created_at AS CreatedAt, + inherited_value AS InheritedValue, + computed AS Computed +FROM persistence_entities +WHERE id = @id;", new { id }); + } + + private static void AssertSqlContains(string sql, params string[] fragments) + { + foreach (var fragment in fragments) + { + Assert.Contains(fragment, sql, StringComparison.OrdinalIgnoreCase); + } + } + + private static void AssertSqlDoesNotContain(string sql, params string[] fragments) + { + foreach (var fragment in fragments) + { + Assert.DoesNotContain(fragment, sql, StringComparison.OrdinalIgnoreCase); + } + } + + private class PersistenceBaseEntity + { + public string InheritedValue { get; set; } + } + + private sealed class PersistenceEntity : PersistenceBaseEntity + { + public int Id { get; set; } + + public string Normal { get; set; } + + public string Ignored { get; set; } + + public string ReadOnly { get; set; } + + public string InsertExcluded { get; set; } + + public string UpdateExcluded { get; set; } + + public string DefaultValue { get; set; } + + public DateTime CreatedAt { get; set; } + + public string Computed { get; set; } + } + + private sealed class PersistenceBaseEntityMap : DommelEntityMap + { + public PersistenceBaseEntityMap() + { + Map(entity => entity.InheritedValue).ToColumn("inherited_value").DatabaseDefaultOnInsert(); + } + } + + private sealed class PersistenceEntityMap : DommelEntityMap + { + public PersistenceEntityMap() + { + ToTable("persistence_entities"); + IncludeBase(); + Map(entity => entity.Id).ToColumn("id").IsIdentity(); + Map(entity => entity.Normal).ToColumn("normal"); + Map(entity => entity.Ignored).ToColumn("ignored").Ignore(); + Map(entity => entity.ReadOnly).ToColumn("read_only").ReadOnly(); + Map(entity => entity.InsertExcluded).ToColumn("insert_excluded").ExcludeFromInsert(); + Map(entity => entity.UpdateExcluded).ToColumn("update_excluded").ExcludeFromUpdate(); + Map(entity => entity.DefaultValue).ToColumn("default_value").DatabaseDefaultOnInsert(); + Map(entity => entity.CreatedAt).ToColumn("created_at").DatabaseDefaultOnInsert(); + Map(entity => entity.Computed).ToColumn("computed").Computed(); + } + } + + private sealed class PersistenceRow + { + public int Id { get; set; } + + public string Normal { get; set; } + + public string Ignored { get; set; } + + public string ReadOnly { get; set; } + + public string InsertExcluded { get; set; } + + public string UpdateExcluded { get; set; } + + public string DefaultValue { get; set; } + + public DateTime CreatedAt { get; set; } + + public string InheritedValue { get; set; } + + public string Computed { get; set; } + } + + private sealed class AssignedKeyEntity + { + public string Code { get; set; } + + public string Name { get; set; } + + public string UpdateExcluded { get; set; } + } + + private sealed class AssignedKeyEntityMap : DommelEntityMap + { + public AssignedKeyEntityMap() + { + ToTable("assigned_key_entities"); + Map(entity => entity.Code).ToColumn("code").IsKey().SetGeneratedOption(DatabaseGeneratedOption.None); + Map(entity => entity.Name).ToColumn("name"); + Map(entity => entity.UpdateExcluded).ToColumn("update_excluded").ExcludeFromUpdate(); + } + } + + private sealed class AssignedKeyRow + { + public string Code { get; set; } + + public string Name { get; set; } + + public string UpdateExcluded { get; set; } + } + + private sealed class CompositePersistenceEntity + { + public int KeyPartOne { get; set; } + + public int KeyPartTwo { get; set; } + + public string Value { get; set; } + } + + private sealed class CompositePersistenceEntityMap : DommelEntityMap + { + public CompositePersistenceEntityMap() + { + ToTable("composite_persistence_entities"); + Map(entity => entity.KeyPartOne).ToColumn("key_part_one").IsKey().SetGeneratedOption(DatabaseGeneratedOption.None); + Map(entity => entity.KeyPartTwo).ToColumn("key_part_two").IsKey().SetGeneratedOption(DatabaseGeneratedOption.None); + Map(entity => entity.Value).ToColumn("value"); + } + } + } +} diff --git a/test/Dapper.FluentMap.Dommel.Tests/ManualMappingTests.cs b/test/Dapper.FluentMap.Dommel.Tests/ManualMappingTests.cs index 9104b2c..a889501 100644 --- a/test/Dapper.FluentMap.Dommel.Tests/ManualMappingTests.cs +++ b/test/Dapper.FluentMap.Dommel.Tests/ManualMappingTests.cs @@ -112,15 +112,15 @@ public void EntityMapsToMultipleKeys() [Fact] - public void PropertiesAreNotGenerated() + public void CompositeKeysShouldRemainNonGeneratedForKeyResolver() { PreTest(); FluentMapper.Initialize(c => c.AddMap(new MapCompositeKeyPropertyMap())); var type = typeof(CompositeKeyEntity); - var propertyResolver = new Dommel.Resolvers.DommelPropertyResolver(); - var properties = propertyResolver.ResolveProperties(type); + var keyResolver = new Dommel.Resolvers.DommelKeyPropertyResolver(); + var properties = keyResolver.ResolveKeyProperties(type); Assert.All(properties, p => Assert.False(p.IsGenerated)); } From eb57d8d49b97b498b8666375eb9a2f455d934967 Mon Sep 17 00:00:00 2001 From: Rodrigo de Oliveira Date: Tue, 28 Jul 2026 13:49:36 -0300 Subject: [PATCH 12/49] feat(diagnostics): validate persistence mapping behavior --- .sdd/etapa-8/06-persistence-diagnostics.md | 120 +++++++++ .sdd/etapa-8/DECISIONS.md | 28 +++ .sdd/etapa-8/STATUS.md | 44 +++- .../AnalyzerReleases.Unshipped.md | 1 + .../FluentMapConfigurationAnalyzer.cs | 233 +++++++++++++++++- .../MappingConfigurationValidator.cs | 125 ++++++++++ .../Dapper.FluentMap.Analyzers.Tests.csproj | 1 + .../FluentMapConfigurationAnalyzerTests.cs | 149 +++++++++++ .../ConfigurationValidationTests.cs | 102 ++++++++ 9 files changed, 799 insertions(+), 4 deletions(-) create mode 100644 .sdd/etapa-8/06-persistence-diagnostics.md diff --git a/.sdd/etapa-8/06-persistence-diagnostics.md b/.sdd/etapa-8/06-persistence-diagnostics.md new file mode 100644 index 0000000..662b212 --- /dev/null +++ b/.sdd/etapa-8/06-persistence-diagnostics.md @@ -0,0 +1,120 @@ +# Etapa 8 - Persistence Diagnostics & Validation + +Status: implementado no Prompt 8.5. + +## Objetivo + +Detectar cedo combinacoes contraditorias de persistence behavior sem transformar +combinacoes legitimas em erro. Diagnostics devem preservar a separacao de +concerns: + +- analyzers reportam apenas cadeias fluentes estaticamente provaveis; +- runtime validation valida a metadata efetiva registrada; +- generated materializers observam somente leitura/materializacao; +- `Explain()` expoe a metadata efetiva para inspecao. + +## Matriz + +```text +Condition Detectable at compile time? Detectable at startup? Severity Diagnostic +Default mapping No Yes None None +ReadOnly Yes, if direct chain Yes None None +ExcludeFromInsert Yes, if direct chain Yes None None +ExcludeFromUpdate Yes, if direct chain Yes None None +ExcludeFromInsert + ExcludeFromUpdate Yes, if direct chain Yes None None +DatabaseDefaultOnInsert Yes, if direct chain Yes None None +DatabaseDefaultOnInsert + ExcludeFromUpdate Yes, if direct chain Yes None None +Computed Yes, if direct chain Yes None None +IsKey + SetGeneratedOption(None) Yes, if direct chain Yes None None +IsIdentity Yes, if direct chain Yes None None +Ignore last in the chain Yes, if direct chain Yes None None +Ignore + ReadOnly Yes, if direct chain Yes Error DFM012 / FluentMapConfigurationException +Ignore + ExcludeFromInsert Yes, if direct chain Yes Error DFM012 / FluentMapConfigurationException +Ignore + ExcludeFromUpdate Yes, if direct chain Yes Error DFM012 / FluentMapConfigurationException +Ignore + Computed Yes, if direct chain Yes Error DFM012 / FluentMapConfigurationException +Ignore + DatabaseDefaultOnInsert Yes, if direct chain Yes Error DFM012 / FluentMapConfigurationException +Ignore + IsKey/IsIdentity Yes, if direct chain Yes Error DFM012 / FluentMapConfigurationException +Computed + DatabaseDefaultOnInsert Yes, if direct chain Yes Error DFM012 / FluentMapConfigurationException +Computed + IsKey Yes, if direct chain Yes Error DFM012 / FluentMapConfigurationException +Computed + IsIdentity Yes, if direct chain Yes Error DFM012 / FluentMapConfigurationException +DatabaseDefaultOnInsert + IsIdentity Yes, if direct chain Yes Error DFM012 / FluentMapConfigurationException +Computed + InsertEnabled No public enabling API Yes, for custom metadata Error FluentMapConfigurationException +Computed + UpdateEnabled No public enabling API Yes, for custom metadata Error FluentMapConfigurationException +Identity + explicit insert requirement No public enabling API Yes, for custom metadata Error FluentMapConfigurationException +Key + UpdateEnabled No public enabling API Yes, for custom metadata Error FluentMapConfigurationException +Null persistence metadata No Yes Error FluentMapConfigurationException +Ignored flag disagrees with metadata No Yes Error FluentMapConfigurationException +Write metadata on generated read materializer Yes Yes None None +``` + +## Runtime validation + +`MappingConfigurationValidator` validates the effective metadata for explicit +maps, composed inherited maps, profiles and conventions. The validation rejects +custom `IPropertyMapWithPersistenceMetadata` implementations when their +metadata is null or contradicts the persistence invariants. + +The validated invariants are: + +- ignored properties do not participate in materialization, insert, update, key + or generated behavior; +- non-ignored properties participate in materialization; +- computed properties are generated read-only values and are not key, identity + or database-default-on-insert values; +- identity properties are generated keys and do not participate in insert, + update or database-default-on-insert behavior; +- key properties do not participate in generated UPDATE SET behavior; +- database-default-on-insert properties are generated values omitted from insert + and are not computed or identity values; +- `IPropertyMap.Ignored` and `Persistence.IgnoredByFluentMap` must agree. + +## Analyzer + +`DFM012` reports invalid persistence behavior when it is directly visible in a +map constructor fluent chain. The analyzer does not execute map constructors, +does not scan assemblies and does not infer behavior from variables or dynamic +control flow. + +Examples reported: + +```csharp +Map(e => e.Name).Ignore().ReadOnly(); +Map(e => e.Total).Computed().DatabaseDefaultOnInsert(); +Map(e => e.Code).Computed().IsKey(); +``` + +Examples intentionally not reported: + +```csharp +Map(e => e.Name).ReadOnly(); +Map(e => e.CreatedAt).DatabaseDefaultOnInsert().ExcludeFromUpdate(); +Map(e => e.Code).IsKey().SetGeneratedOption(DatabaseGeneratedOption.None); +``` + +## Generated source diagnostics + +Generated materializers continue to treat persistence write metadata as neutral +for reads. `ExcludeFromInsert()`, `ExcludeFromUpdate()`, `ReadOnly()`, +`Computed()` and `DatabaseDefaultOnInsert()` must not produce generated +materializer fallback diagnostics by themselves. + +Only `Ignore()` changes generated read behavior, because it disables +materialization. + +## Explain API + +No new public API was required in this prompt. `Explain()` and +`Explain()` already expose `MemberMappingExplanation.Persistence`, +which provides: + +```text +Read: ParticipatesInMaterialization +Insert: ParticipatesInInsert +Update: ParticipatesInUpdate +Generated: IsGenerated / IsComputed / IsIdentity / HasDatabaseDefaultOnInsert +``` + +Future work can add a formatted display helper if API usability research shows +that consumers need a textual summary, but the structured metadata is the +stable public contract. + diff --git a/.sdd/etapa-8/DECISIONS.md b/.sdd/etapa-8/DECISIONS.md index 450e58a..ff1a229 100644 --- a/.sdd/etapa-8/DECISIONS.md +++ b/.sdd/etapa-8/DECISIONS.md @@ -262,3 +262,31 @@ Adicionar `MemberMappingExplanation.Persistence`. `FluentMapper.Explain()` passa a expor a metadata efetiva sem acoplar diagnostics a SQL ou a Dommel. + +## ADR-13 - Diagnostics conservadores para persistence behavior + +### Contexto + +A metadata de persistencia agora permite distinguir materializacao, insert, +update, key, identity, computed e default-on-insert. Algumas combinacoes sao +contraditorias, mas nem toda configuracao pode ser provada estaticamente sem +executar construtores de maps. + +### Decisao + +Usar duas camadas: + +- analyzer `DFM012` para combinacoes contraditorias visiveis diretamente na + fluent chain do construtor do map; +- runtime validation para a metadata efetiva registrada, incluindo maps + customizados que implementem `IPropertyMapWithPersistenceMetadata`. + +O analyzer nao executa construtores, nao faz scan de assemblies e nao emite +diagnostics para metadata de escrita que nao afeta materializacao gerada. + +### Consequencias + +Erros comuns aparecem durante compilacao quando a cadeia e literal. Estados +customizados, herdados ou compostos continuam protegidos por +`FluentMapper.Validate()` e pelos caminhos de registro. Generated materializers +permanecem focados em leitura. diff --git a/.sdd/etapa-8/STATUS.md b/.sdd/etapa-8/STATUS.md index 9041839..322f99c 100644 --- a/.sdd/etapa-8/STATUS.md +++ b/.sdd/etapa-8/STATUS.md @@ -122,6 +122,36 @@ execucao de CRUD ao core. sucesso, 0 warnings, 0 errors. - Executado `dotnet test .\Dapper.FluentMap.sln --configuration Release --no-build`: sucesso, 281 testes aprovados. +- Criado `.sdd/etapa-8/06-persistence-diagnostics.md` com matriz de + diagnostics e validacao. +- Implementada validacao runtime dos invariants de persistence metadata em + `MappingConfigurationValidator`. +- Adicionado analyzer `DFM012` para combinacoes contraditorias em fluent chains + estaticamente visiveis. +- Confirmado que metadata de escrita continua neutra para generated + materializers; `ExcludeFromInsert()` e equivalentes nao geram fallback warning + por si so. +- Mantida a API `Explain()` sem nova superficie publica: a metadata + estruturada existente em `MemberMappingExplanation.Persistence` ja expoe read, + insert, update e generated/computed/default/identity. +- Adicionados testes para combinacoes validas, invalidas, diagnostics do + analyzer, diagnostics runtime e preservacao dos casos inherited/profile ja + cobertos pela suite de persistence metadata. +- Executado `dotnet test .\test\Dapper.FluentMap.Tests\Dapper.FluentMap.Tests.csproj --configuration Release --filter "FullyQualifiedName~ConfigurationValidationTests"`: + sucesso, 13 testes aprovados. +- Executado `dotnet test .\test\Dapper.FluentMap.Analyzers.Tests\Dapper.FluentMap.Analyzers.Tests.csproj --configuration Release --filter "FullyQualifiedName~FluentMapConfigurationAnalyzerTests"`: + sucesso, 14 testes aprovados. +- Executado `dotnet restore .\Dapper.FluentMap.sln`: sucesso. +- Executado `dotnet build .\Dapper.FluentMap.sln --configuration Release --no-restore`: + sucesso, 0 warnings, 0 errors. +- Executado `dotnet test .\Dapper.FluentMap.sln --configuration Release --no-build`: + sucesso, 288 testes aprovados. +- Executado `dotnet pack .\src\Dapper.FluentMap\Dapper.FluentMap.csproj --configuration Release --no-build --output .\artifacts\packages`: + sucesso; warning legado `NU5125` sobre `PackageLicenseUrl`/`licenseUrl`. +- Executado `dotnet pack .\src\Dapper.FluentMap.Analyzers\Dapper.FluentMap.Analyzers.csproj --configuration Release --no-build --output .\artifacts\packages`: + sucesso. +- Inspecionados `Dapper.FluentMap.2.0.0.nupkg` e + `Dapper.FluentMap.Analyzers.2.0.0.nupkg`; conteudos esperados preservados. ## Em andamento @@ -129,8 +159,10 @@ Nenhum apos a validacao final deste prompt. ## Proximos passos -1. Atualizar analyzers/source generator para reconhecer a nova DSL. -2. Fazer hardening de cache, profiles, generated materializers e Dommel SQL real. +1. Avaliar helper textual opcional para `Explain` se usuarios pedirem uma + representacao pronta para logs. +2. Fazer hardening de cache e cenarios Dommel provider-specific quando houver + demanda real. ## Decisoes relevantes @@ -145,6 +177,9 @@ Nenhum apos a validacao final deste prompt. - Generated materializers observam apenas semantica de leitura. - `IPropertyMap` nao foi alterada; metadata nova fica em interface opcional. - `Explain()` ja expoe metadata de persistencia. +- `DFM012` reporta persistence behavior contraditorio apenas quando provado + estaticamente em uma fluent chain direta. +- Runtime validation valida a metadata efetiva e protege maps customizados. ## Issues historicas @@ -181,6 +216,8 @@ Nenhum apos a validacao final deste prompt. embora a metadata de core diferencie key de identity. - SQL builders customizados registrados depois de `ForDommel()` substituem o wrapper padrao e precisam honrar `ParticipatesInInsert` por conta propria. +- O analyzer nao infere combinacoes construidas por variaveis, helpers ou fluxo + condicional; esses casos dependem da validacao runtime. ## Arquivos importantes @@ -204,7 +241,8 @@ Nenhum apos a validacao final deste prompt. - `src/Dapper.FluentMap.Analyzers/FluentMapConfigurationAnalyzer.cs` - `.sdd/etapa-8/04-read-semantics.md` - `.sdd/etapa-8/05-dommel-persistence-behavior.md` +- `.sdd/etapa-8/06-persistence-diagnostics.md` ## Ultimo prompt executado -Ultimo prompt executado: 8.4 +Ultimo prompt executado: 8.5 diff --git a/src/Dapper.FluentMap.Analyzers/AnalyzerReleases.Unshipped.md b/src/Dapper.FluentMap.Analyzers/AnalyzerReleases.Unshipped.md index 5abf163..40c22d3 100644 --- a/src/Dapper.FluentMap.Analyzers/AnalyzerReleases.Unshipped.md +++ b/src/Dapper.FluentMap.Analyzers/AnalyzerReleases.Unshipped.md @@ -9,3 +9,4 @@ DFM004 | Dapper.FluentMap.Configuration | Error | Included mapping type must be 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. +DFM012 | Dapper.FluentMap.Configuration | Error | Persistence mapping behavior is invalid. diff --git a/src/Dapper.FluentMap.Analyzers/FluentMapConfigurationAnalyzer.cs b/src/Dapper.FluentMap.Analyzers/FluentMapConfigurationAnalyzer.cs index 349798a..3a9eb16 100644 --- a/src/Dapper.FluentMap.Analyzers/FluentMapConfigurationAnalyzer.cs +++ b/src/Dapper.FluentMap.Analyzers/FluentMapConfigurationAnalyzer.cs @@ -20,10 +20,12 @@ public sealed class FluentMapConfigurationAnalyzer : DiagnosticAnalyzer public const string InvalidGenericMapRegistrationDiagnosticId = "DFM005"; public const string InvalidGenericProfileRegistrationDiagnosticId = "DFM009"; public const string DuplicateProfileRegistrationDiagnosticId = "DFM010"; + public const string InvalidPersistenceBehaviorDiagnosticId = "DFM012"; private const string Category = "Dapper.FluentMap.Configuration"; private const string MappingNamespace = "Dapper.FluentMap.Mapping"; private const string ConfigurationNamespace = "Dapper.FluentMap.Configuration"; + private const string DommelMappingNamespace = "Dapper.FluentMap.Dommel.Mapping"; private static readonly DiagnosticDescriptor InvalidMapExpressionRule = new DiagnosticDescriptor( InvalidMapExpressionDiagnosticId, @@ -91,6 +93,15 @@ public sealed class FluentMapConfigurationAnalyzer : DiagnosticAnalyzer description: "The same entity/profile pair must not be registered more than once.", customTags: WellKnownDiagnosticTags.CompilationEnd); + private static readonly DiagnosticDescriptor InvalidPersistenceBehaviorRule = new DiagnosticDescriptor( + InvalidPersistenceBehaviorDiagnosticId, + "Persistence mapping behavior is invalid", + "Property path '{0}' has invalid persistence behavior: {1}", + Category, + DiagnosticSeverity.Error, + isEnabledByDefault: true, + description: "Persistence mapping calls such as Ignore, Computed, DatabaseDefaultOnInsert, key and identity must not be combined in contradictory ways."); + public override ImmutableArray SupportedDiagnostics => ImmutableArray.Create( InvalidMapExpressionRule, @@ -99,7 +110,8 @@ public sealed class FluentMapConfigurationAnalyzer : DiagnosticAnalyzer InvalidIncludeBaseRule, InvalidGenericMapRegistrationRule, InvalidGenericProfileRegistrationRule, - DuplicateProfileRegistrationRule); + DuplicateProfileRegistrationRule, + InvalidPersistenceBehaviorRule); public override void Initialize(AnalysisContext context) { @@ -191,6 +203,7 @@ private static void AnalyzeMapInvocation( invocation, context.SemanticModel, memberPath, + context, context.CancellationToken, out var mapInvocation)) { @@ -418,6 +431,7 @@ private static bool TryCreateDirectConstructorMapInvocation( InvocationExpressionSyntax mapInvocation, SemanticModel semanticModel, MemberPathInfo memberPath, + SyntaxNodeAnalysisContext context, System.Threading.CancellationToken cancellationToken, out MapInvocation result) { @@ -442,6 +456,7 @@ private static bool TryCreateDirectConstructorMapInvocation( var caseSensitive = true; var ignored = false; var columnLocation = mapInvocation.GetLocation(); + var persistenceState = new PersistenceChainState(); SyntaxNode current = mapInvocation; while (current.Parent is MemberAccessExpressionSyntax memberAccess && @@ -465,6 +480,18 @@ private static bool TryCreateDirectConstructorMapInvocation( else if (IsIgnoreInvocation(chainedMethod)) { ignored = true; + persistenceState.ApplyIgnore(); + } + else if (TryGetPersistenceAction(chainedMethod, chainedInvocation, semanticModel, cancellationToken, out var persistenceAction)) + { + if (!persistenceState.TryApply(persistenceAction, out var reason)) + { + context.ReportDiagnostic(Diagnostic.Create( + InvalidPersistenceBehaviorRule, + GetInvocationNameLocation(chainedInvocation), + memberPath.Display, + reason)); + } } current = chainedInvocation; @@ -665,6 +692,96 @@ private static bool IsIgnoreInvocation(IMethodSymbol method) return method.Name == "Ignore" && method.Parameters.Length == 0; } + private static bool TryGetPersistenceAction( + IMethodSymbol method, + InvocationExpressionSyntax invocation, + SemanticModel semanticModel, + System.Threading.CancellationToken cancellationToken, + out PersistenceAction action) + { + action = PersistenceAction.None; + + if (method == null || !IsPersistenceMethod(method)) + { + return false; + } + + if (method.Parameters.Length == 0) + { + switch (method.Name) + { + case "ExcludeFromInsert": + action = PersistenceAction.ExcludeFromInsert; + return true; + case "ExcludeFromUpdate": + action = PersistenceAction.ExcludeFromUpdate; + return true; + case "ReadOnly": + action = PersistenceAction.ReadOnly; + return true; + case "Computed": + action = PersistenceAction.Computed; + return true; + case "DatabaseDefaultOnInsert": + action = PersistenceAction.DatabaseDefaultOnInsert; + return true; + case "IsKey": + action = PersistenceAction.Key; + return true; + case "IsIdentity": + action = PersistenceAction.Identity; + return true; + } + } + + if (method.Name == "SetGeneratedOption" && + method.Parameters.Length == 1 && + invocation.ArgumentList.Arguments.Count == 1) + { + var option = semanticModel.GetConstantValue( + invocation.ArgumentList.Arguments[0].Expression, + cancellationToken); + if (!option.HasValue || !(option.Value is int optionValue)) + { + return false; + } + + switch (optionValue) + { + case 0: + action = PersistenceAction.GeneratedNone; + return true; + case 1: + action = PersistenceAction.GeneratedIdentity; + return true; + case 2: + action = PersistenceAction.GeneratedComputed; + return true; + } + } + + return false; + } + + private static bool IsPersistenceMethod(IMethodSymbol method) + { + var containingType = method.ContainingType; + if (IsType(containingType, DommelMappingNamespace, "DommelPropertyMap")) + { + return true; + } + + for (var current = containingType; current != null; current = current.BaseType) + { + if (IsType(current.OriginalDefinition, MappingNamespace, "PropertyMapBase`1")) + { + return true; + } + } + + return false; + } + private static Location GetInvocationNameLocation(InvocationExpressionSyntax invocation) { var memberAccess = invocation.Expression as MemberAccessExpressionSyntax; @@ -832,5 +949,119 @@ internal ProfileRegistrationInvocation( internal Location Location { get; } } + + private enum PersistenceAction + { + None, + ExcludeFromInsert, + ExcludeFromUpdate, + ReadOnly, + Computed, + DatabaseDefaultOnInsert, + Key, + Identity, + GeneratedNone, + GeneratedComputed, + GeneratedIdentity + } + + private sealed class PersistenceChainState + { + private bool _ignored; + private bool _computed; + private bool _databaseDefaultOnInsert; + private bool _key; + private bool _identity; + + internal void ApplyIgnore() + { + _ignored = true; + } + + internal bool TryApply(PersistenceAction action, out string reason) + { + reason = null; + + if (_ignored) + { + reason = "Ignore() disables materialization and persistence metadata; write persistence calls cannot be applied after Ignore()."; + return false; + } + + switch (action) + { + case PersistenceAction.Computed: + case PersistenceAction.GeneratedComputed: + if (_databaseDefaultOnInsert) + { + reason = "computed values cannot also be configured with DatabaseDefaultOnInsert()."; + return false; + } + + if (_key) + { + reason = "computed values cannot also be configured as keys."; + return false; + } + + if (_identity) + { + reason = "computed values cannot also be configured as identity values."; + return false; + } + + _computed = true; + return true; + case PersistenceAction.DatabaseDefaultOnInsert: + if (_computed) + { + reason = "DatabaseDefaultOnInsert() cannot be combined with computed persistence semantics."; + return false; + } + + if (_identity) + { + reason = "DatabaseDefaultOnInsert() cannot be combined with identity persistence semantics."; + return false; + } + + _databaseDefaultOnInsert = true; + return true; + case PersistenceAction.Key: + if (_computed) + { + reason = "key persistence semantics cannot be combined with computed values."; + return false; + } + + _key = true; + return true; + case PersistenceAction.Identity: + case PersistenceAction.GeneratedIdentity: + if (_computed) + { + reason = "identity persistence semantics cannot be combined with computed values."; + return false; + } + + if (_databaseDefaultOnInsert) + { + reason = "identity persistence semantics cannot be combined with DatabaseDefaultOnInsert()."; + return false; + } + + _key = true; + _identity = true; + return true; + case PersistenceAction.GeneratedNone: + _identity = false; + _computed = false; + _databaseDefaultOnInsert = false; + return true; + default: + return true; + } + } + } } } diff --git a/src/Dapper.FluentMap/MappingConfigurationValidator.cs b/src/Dapper.FluentMap/MappingConfigurationValidator.cs index 49d5868..8f9d8e5 100644 --- a/src/Dapper.FluentMap/MappingConfigurationValidator.cs +++ b/src/Dapper.FluentMap/MappingConfigurationValidator.cs @@ -150,9 +150,134 @@ private static MapDescriptor CreateDescriptor(Type entityType, IPropertyMap map, $"Property path '{memberPath}' on entity '{FormatType(entityType)}' has an empty column name."); } + ValidatePersistenceMetadata(entityType, map, memberPath, sourceKind, sourceType); + return new MapDescriptor(map, memberPath); } + private static void ValidatePersistenceMetadata(Type entityType, IPropertyMap map, MemberPath memberPath, string sourceKind, Type sourceType) + { + var persistence = PropertyMapPersistence.GetPersistence(map); + if (persistence == null) + { + throw InvalidPersistenceMetadata( + entityType, + memberPath, + sourceKind, + sourceType, + "Persistence metadata cannot be null."); + } + + if (map.Ignored != persistence.IgnoredByFluentMap) + { + throw InvalidPersistenceMetadata( + entityType, + memberPath, + sourceKind, + sourceType, + "The Ignored flag and persistence metadata disagree."); + } + + if (persistence.IgnoredByFluentMap) + { + if (persistence.ParticipatesInMaterialization || + persistence.ParticipatesInInsert || + persistence.ParticipatesInUpdate || + persistence.IsKey || + persistence.IsIdentity || + persistence.IsGenerated || + persistence.IsComputed || + persistence.HasDatabaseDefaultOnInsert) + { + throw InvalidPersistenceMetadata( + entityType, + memberPath, + sourceKind, + sourceType, + "Ignored properties cannot participate in materialization, insert, update, key or generated persistence behavior."); + } + + return; + } + + if (!persistence.ParticipatesInMaterialization) + { + throw InvalidPersistenceMetadata( + entityType, + memberPath, + sourceKind, + sourceType, + "Only ignored properties may opt out of FluentMap materialization."); + } + + if (persistence.IsComputed) + { + if (!persistence.IsGenerated || + persistence.ParticipatesInInsert || + persistence.ParticipatesInUpdate || + persistence.HasDatabaseDefaultOnInsert || + persistence.IsKey || + persistence.IsIdentity) + { + throw InvalidPersistenceMetadata( + entityType, + memberPath, + sourceKind, + sourceType, + "Computed properties must be generated read-only values and cannot also be key, identity or database-default properties."); + } + } + + if (persistence.IsIdentity) + { + if (!persistence.IsGenerated || + !persistence.IsKey || + persistence.ParticipatesInInsert || + persistence.ParticipatesInUpdate || + persistence.HasDatabaseDefaultOnInsert) + { + throw InvalidPersistenceMetadata( + entityType, + memberPath, + sourceKind, + sourceType, + "Identity properties must be generated keys and cannot participate in insert, update or database-default-on-insert behavior."); + } + } + + if (persistence.IsKey && persistence.ParticipatesInUpdate) + { + throw InvalidPersistenceMetadata( + entityType, + memberPath, + sourceKind, + sourceType, + "Key properties cannot participate in generated UPDATE SET behavior."); + } + + if (persistence.HasDatabaseDefaultOnInsert) + { + if (!persistence.IsGenerated || + persistence.ParticipatesInInsert || + persistence.IsComputed || + persistence.IsIdentity) + { + throw InvalidPersistenceMetadata( + entityType, + memberPath, + sourceKind, + sourceType, + "Database-default-on-insert properties must be generated values omitted from insert and cannot also be computed or identity properties."); + } + } + } + + private static FluentMapConfigurationException InvalidPersistenceMetadata(Type entityType, MemberPath memberPath, string sourceKind, Type sourceType, string reason) + { + return new FluentMapConfigurationException( + $"Property path '{memberPath}' on entity '{FormatType(entityType)}' has invalid persistence metadata in {sourceKind} '{FormatType(sourceType)}'. {reason}"); + } + private static void ValidateDuplicateMemberPaths(Type entityType, IList maps, string sourceKind, Type sourceType) { for (var i = 0; i < maps.Count; i++) diff --git a/test/Dapper.FluentMap.Analyzers.Tests/Dapper.FluentMap.Analyzers.Tests.csproj b/test/Dapper.FluentMap.Analyzers.Tests/Dapper.FluentMap.Analyzers.Tests.csproj index f2dc9d9..da5e338 100644 --- a/test/Dapper.FluentMap.Analyzers.Tests/Dapper.FluentMap.Analyzers.Tests.csproj +++ b/test/Dapper.FluentMap.Analyzers.Tests/Dapper.FluentMap.Analyzers.Tests.csproj @@ -5,6 +5,7 @@ + diff --git a/test/Dapper.FluentMap.Analyzers.Tests/FluentMapConfigurationAnalyzerTests.cs b/test/Dapper.FluentMap.Analyzers.Tests/FluentMapConfigurationAnalyzerTests.cs index c2dc409..a3dba53 100644 --- a/test/Dapper.FluentMap.Analyzers.Tests/FluentMapConfigurationAnalyzerTests.cs +++ b/test/Dapper.FluentMap.Analyzers.Tests/FluentMapConfigurationAnalyzerTests.cs @@ -228,6 +228,153 @@ public void Configure(FluentMapConfiguration configuration) AssertDiagnosticLineContains(source, diagnostic, ".AddProfile()"); } + [Fact] + public async Task PersistenceConfigurationAfterIgnoreShouldReportDfm012() + { + var source = @" +using Dapper.FluentMap.Mapping; + +public sealed class Customer +{ + public string Name { get; set; } +} + +public sealed class CustomerMap : EntityMap +{ + public CustomerMap() + { + Map(c => c.Name).ToColumn(""customer_name"").Ignore().ReadOnly(); + } +}"; + + var diagnostic = await GetSingleDiagnosticAsync(source, FluentMapConfigurationAnalyzer.InvalidPersistenceBehaviorDiagnosticId); + + AssertDiagnostic(diagnostic, DiagnosticSeverity.Error, "Property path 'Name' has invalid persistence behavior"); + AssertDiagnostic(diagnostic, DiagnosticSeverity.Error, "after Ignore()"); + AssertDiagnosticLineContains(source, diagnostic, "ReadOnly()"); + } + + [Fact] + public async Task ComputedAndDatabaseDefaultShouldReportDfm012() + { + var source = @" +using Dapper.FluentMap.Mapping; + +public sealed class Customer +{ + public string Total { get; set; } +} + +public sealed class CustomerMap : EntityMap +{ + public CustomerMap() + { + Map(c => c.Total).Computed().DatabaseDefaultOnInsert(); + } +}"; + + var diagnostic = await GetSingleDiagnosticAsync(source, FluentMapConfigurationAnalyzer.InvalidPersistenceBehaviorDiagnosticId); + + AssertDiagnostic(diagnostic, DiagnosticSeverity.Error, "DatabaseDefaultOnInsert() cannot be combined with computed persistence semantics"); + AssertDiagnosticLineContains(source, diagnostic, "DatabaseDefaultOnInsert()"); + } + + [Fact] + public async Task ComputedAndKeyShouldReportDfm012() + { + var source = @" +using Dapper.FluentMap.Dommel.Mapping; + +public sealed class Customer +{ + public string Code { get; set; } +} + +public sealed class CustomerMap : DommelEntityMap +{ + public CustomerMap() + { + Map(c => c.Code).Computed().IsKey(); + } +}"; + + var diagnostic = await GetSingleDiagnosticAsync(source, FluentMapConfigurationAnalyzer.InvalidPersistenceBehaviorDiagnosticId); + + AssertDiagnostic(diagnostic, DiagnosticSeverity.Error, "key persistence semantics cannot be combined with computed values"); + AssertDiagnosticLineContains(source, diagnostic, "IsKey()"); + } + + [Fact] + public async Task GeneratedOptionComputedAndDatabaseDefaultShouldReportDfm012() + { + var source = @" +using System.ComponentModel.DataAnnotations.Schema; +using Dapper.FluentMap.Dommel.Mapping; + +public sealed class Customer +{ + public string Total { get; set; } +} + +public sealed class CustomerMap : DommelEntityMap +{ + public CustomerMap() + { + Map(c => c.Total) + .SetGeneratedOption(DatabaseGeneratedOption.Computed) + .DatabaseDefaultOnInsert(); + } +}"; + + var diagnostic = await GetSingleDiagnosticAsync(source, FluentMapConfigurationAnalyzer.InvalidPersistenceBehaviorDiagnosticId); + + AssertDiagnostic(diagnostic, DiagnosticSeverity.Error, "DatabaseDefaultOnInsert() cannot be combined with computed persistence semantics"); + AssertDiagnosticLineContains(source, diagnostic, "DatabaseDefaultOnInsert()"); + } + + [Fact] + public async Task ValidPersistenceCombinationsShouldNotReportDfm012() + { + var source = @" +using System.ComponentModel.DataAnnotations.Schema; +using Dapper.FluentMap.Dommel.Mapping; + +public sealed class Customer +{ + public int Id { get; set; } + + public string Code { get; set; } + + public string ReadOnlyName { get; set; } + + public string InsertExcluded { get; set; } + + public string UpdateExcluded { get; set; } + + public string DefaultValue { get; set; } + + public string ComputedValue { get; set; } +} + +public sealed class CustomerMap : DommelEntityMap +{ + public CustomerMap() + { + Map(c => c.Id).IsIdentity(); + Map(c => c.Code).IsKey().SetGeneratedOption(DatabaseGeneratedOption.None); + Map(c => c.ReadOnlyName).ReadOnly(); + Map(c => c.InsertExcluded).ExcludeFromInsert(); + Map(c => c.UpdateExcluded).ExcludeFromUpdate(); + Map(c => c.DefaultValue).DatabaseDefaultOnInsert().ExcludeFromUpdate(); + Map(c => c.ComputedValue).Computed(); + } +}"; + + var diagnostics = await GetAnalyzerDiagnosticsAsync(source); + + Assert.DoesNotContain(diagnostics, diagnostic => diagnostic.Id == FluentMapConfigurationAnalyzer.InvalidPersistenceBehaviorDiagnosticId); + } + [Fact] public async Task ValidMappingConfigurationShouldNotReportDiagnostics() { @@ -392,6 +539,8 @@ private static IReadOnlyList GetMetadataReferences() var explicitAssemblies = new[] { typeof(FluentMapper).Assembly.Location, + typeof(Dapper.FluentMap.Dommel.Mapping.DommelEntityMap<>).Assembly.Location, + typeof(global::Dommel.DommelMapper).Assembly.Location, typeof(Dapper.SqlMapper).Assembly.Location } .Select(path => MetadataReference.CreateFromFile(path)); diff --git a/test/Dapper.FluentMap.Tests/ConfigurationValidationTests.cs b/test/Dapper.FluentMap.Tests/ConfigurationValidationTests.cs index 0c90802..d26ba21 100644 --- a/test/Dapper.FluentMap.Tests/ConfigurationValidationTests.cs +++ b/test/Dapper.FluentMap.Tests/ConfigurationValidationTests.cs @@ -128,6 +128,48 @@ public void ExternalPropertyMapsWithSameColumnShouldRemainValid() Assert.True(FluentMapper.EntityMaps.ContainsKey(typeof(ColumnConflictEntity))); } + [Fact] + public void ValidPersistenceCombinationsShouldPassRuntimeValidation() + { + PreTest(typeof(PersistenceValidationEntity)); + + try + { + FluentMapper.Initialize(c => c.AddMap(new ValidPersistenceValidationMap())); + + FluentMapper.Validate(); + + var explanation = FluentMapper.Explain(); + Assert.Contains(explanation.Members, member => + member.MemberPath == nameof(PersistenceValidationEntity.ReadOnlyValue) && + member.Persistence.ParticipatesInMaterialization && + !member.Persistence.ParticipatesInInsert && + !member.Persistence.ParticipatesInUpdate); + Assert.Contains(explanation.Members, member => + member.MemberPath == nameof(PersistenceValidationEntity.DefaultValue) && + member.Persistence.HasDatabaseDefaultOnInsert && + !member.Persistence.ParticipatesInInsert && + member.Persistence.ParticipatesInUpdate); + } + finally + { + PreTest(typeof(PersistenceValidationEntity)); + } + } + + [Fact] + public void InvalidPersistenceMetadataShouldThrowUsefulConfigurationException() + { + PreTest(typeof(PersistenceValidationEntity)); + + var exception = Assert.Throws(() => + FluentMapper.Initialize(c => c.AddMap(new InvalidPersistenceValidationMap()))); + + Assert.Contains(nameof(PersistenceValidationEntity.ReadOnlyValue), exception.Message); + Assert.Contains("invalid persistence metadata", exception.Message); + Assert.Contains("Ignored flag and persistence metadata disagree", exception.Message); + } + [Fact] public void ConventionWithoutConfigureShouldThrowConfigurationException() { @@ -241,6 +283,66 @@ public MissingConfigureConvention() } } + private class PersistenceValidationEntity + { + public int Id { get; set; } + + public string ReadOnlyValue { get; set; } + + public string InsertExcluded { get; set; } + + public string UpdateExcluded { get; set; } + + public string DefaultValue { get; set; } + + public string ComputedValue { get; set; } + } + + private class ValidPersistenceValidationMap : EntityMap + { + public ValidPersistenceValidationMap() + { + Map(e => e.Id); + Map(e => e.ReadOnlyValue).ReadOnly(); + Map(e => e.InsertExcluded).ExcludeFromInsert(); + Map(e => e.UpdateExcluded).ExcludeFromUpdate(); + Map(e => e.DefaultValue).DatabaseDefaultOnInsert(); + Map(e => e.ComputedValue).Computed(); + } + } + + private class InvalidPersistenceValidationMap : IEntityMap + { + public InvalidPersistenceValidationMap() + { + PropertyMaps = new List + { + new InvalidPersistencePropertyMap( + typeof(PersistenceValidationEntity).GetProperty(nameof(PersistenceValidationEntity.ReadOnlyValue))) + }; + } + + public IList PropertyMaps { get; } + } + + private class InvalidPersistencePropertyMap : IPropertyMap, IPropertyMapWithPersistenceMetadata + { + public InvalidPersistencePropertyMap(PropertyInfo propertyInfo) + { + PropertyInfo = propertyInfo; + } + + public string ColumnName => PropertyInfo.Name; + + public PropertyInfo PropertyInfo { get; } + + public bool CaseSensitive => true; + + public bool Ignored => true; + + public PropertyPersistenceMetadata Persistence => PropertyPersistenceMetadata.Default; + } + private class NestedLevelEntity { public RankInfo Rank { get; set; } From 09a6f9e8981669eadbb964fb1a7e31459b39dab7 Mon Sep 17 00:00:00 2001 From: Rodrigo de Oliveira Date: Tue, 28 Jul 2026 14:04:46 -0300 Subject: [PATCH 13/49] test(regression): cover historical FluentMap issues --- .sdd/etapa-8/01-historical-issues.md | 69 ++++ .../etapa-8/07-historical-regression-suite.md | 91 +++++ .sdd/etapa-8/STATUS.md | 31 +- .../DommelHistoricalRegressionTests.cs | 368 ++++++++++++++++++ .../HistoricalCoreRegressionTests.cs | 253 ++++++++++++ 5 files changed, 811 insertions(+), 1 deletion(-) create mode 100644 .sdd/etapa-8/07-historical-regression-suite.md create mode 100644 test/Dapper.FluentMap.Dommel.Tests/HistoricalRegression/DommelHistoricalRegressionTests.cs create mode 100644 test/Dapper.FluentMap.Tests/HistoricalRegression/HistoricalCoreRegressionTests.cs diff --git a/.sdd/etapa-8/01-historical-issues.md b/.sdd/etapa-8/01-historical-issues.md index 372ae5c..d5dafb7 100644 --- a/.sdd/etapa-8/01-historical-issues.md +++ b/.sdd/etapa-8/01-historical-issues.md @@ -70,6 +70,17 @@ Evidencia: `ReadOnly()` e consumido pelo `Dapper.FluentMap.Dommel` como `DommelPersistenceIntegrationTests.InsertUpdateAndSelectShouldHonorPropertyPersistenceMetadata`, que valida insert, update e leitura posterior via Dommel/SQLite. +### Evidencia apos Prompt 8.6 + +Regression suite explicita: + +- `DommelHistoricalRegressionTests.ReadOnlyPropertyShouldBeMaterializedButExcludedFromWrites` + valida que a coluna read-only e omitida de INSERT/UPDATE e ainda e lida por + `Dommel.Get`. +- `HistoricalCoreRegressionTests.GeneratedAndRuntimeMaterializersShouldAgreeForHistoricalReadSemantics` + valida que read-only continua materializando igualmente no runtime materializer + e no generated materializer. + ### Evidencia historica - `src/Dapper.FluentMap/Mapping/PropertyMap.cs` @@ -141,6 +152,14 @@ e nao entram no SET do UPDATE. Coberto por Observacao de compatibilidade: `IsKey()` sem `SetGeneratedOption(None)` continua identity operacional no key resolver Dommel para preservar maps antigos. +### Evidencia apos Prompt 8.6 + +Regression suite explicita: + +- `DommelHistoricalRegressionTests.NonIdentityKeyShouldBeInsertedAndOnlyUsedForUpdateWhereClause` + valida INSERT real contendo a key atribuida pela aplicacao e UPDATE real usando + a key apenas no `WHERE`, nao no `SET`. + ### Evidencia historica - `src/Dapper.FluentMap.Dommel/Mapping/DommelPropertyMap.cs` @@ -205,6 +224,17 @@ sao traduzidos para omissao de INSERT e UPDATE, preservando leitura. Coberto por `DommelPersistenceIntegrationTests.InsertUpdateAndSelectShouldHonorPropertyPersistenceMetadata` com coluna SQLite generated. +### Evidencia apos Prompt 8.6 + +Regression suite explicita: + +- `DommelHistoricalRegressionTests.ComputedPropertyShouldBeReadButExcludedFromInsertAndUpdate` + usa a API historica `SetGeneratedOption(DatabaseGeneratedOption.Computed)`, + valida SQL real sem a coluna computed em INSERT/UPDATE e confirma leitura do + valor gerado pelo SQLite. +- `HistoricalCoreRegressionTests.GeneratedAndRuntimeMaterializersShouldAgreeForHistoricalReadSemantics` + confirma que metadata computed nao desabilita materializacao de leitura. + ### Evidencia historica - `src/Dapper.FluentMap.Dommel/Resolvers/DommelPropertyResolver.cs` @@ -263,6 +293,16 @@ posterior e preserva UPDATE por default. A regressao usa coluna `created_at TEXT DEFAULT CURRENT_TIMESTAMP` em `DommelPersistenceIntegrationTests.InsertUpdateAndSelectShouldHonorPropertyPersistenceMetadata`. +### Evidencia apos Prompt 8.6 + +Regression suite explicita: + +- `DommelHistoricalRegressionTests.DatabaseDefaultOnInsertShouldOmitInsertColumnAndReadDatabaseValue` + valida que `DatabaseDefaultOnInsert()` omite a coluna no INSERT e le o valor + default do banco sem `Ignore()`. +- `HistoricalCoreRegressionTests.GeneratedAndRuntimeMaterializersShouldAgreeForHistoricalReadSemantics` + confirma que database-default-on-insert continua neutro para materializacao. + ### Evidencia historica - `src/Dapper.FluentMap/Compatibility/DapperIgnoredMemberMap.cs` @@ -316,6 +356,14 @@ nome. Ja resolvido; manter como regression boundary. +### Evidencia apos Prompt 8.6 + +Regression suite explicita: + +- `HistoricalCoreRegressionTests.PropertyNamedLikeBclMemberShouldMapExpressionProperty` + valida materializacao Dapper de propriedade chamada `Format`, preservando o + uso do `MemberInfo` real da expression tree. + ### Evidencia - `src/Dapper.FluentMap/Utils/ReflectionHelper.cs` @@ -369,6 +417,16 @@ flat. Ja resolvido no core/generated; apenas regression test se a etapa tocar nessa area. +### Evidencia apos Prompt 8.6 + +Regression suite explicita: + +- `HistoricalCoreRegressionTests.NestedMemberPathsWithSameTerminalNameShouldMaterializeDistinctValues` + valida tres caminhos aninhados terminando em `Level` com valores distintos. +- `HistoricalCoreRegressionTests.GeneratedAndRuntimeMaterializersShouldAgreeForHistoricalReadSemantics` + compara runtime e generated materializer para paths `Rank.Level` e + `Seniority.Level`. + ### Evidencia - `src/Dapper.FluentMap/Mapping/MemberPath.cs` @@ -429,6 +487,17 @@ participacao em leitura e escrita. Ja resolvido para o bug original; manter regression boundary e nao reutilizar `Ignore()` para semantica de escrita. +### Evidencia apos Prompt 8.6 + +Regression suite explicita: + +- `HistoricalCoreRegressionTests.IgnoredPropertySelectedByDapperShouldRemainUnmappedWithoutThrowing` + valida `Dapper.Query()` selecionando coluna ignorada sem + `NotImplementedException` e sem materializar a propriedade. +- `HistoricalCoreRegressionTests.GeneratedAndRuntimeMaterializersShouldAgreeForHistoricalReadSemantics` + confirma que `Ignore()` permanece semantica de leitura tanto no runtime quanto + no generated materializer. + ### Evidencia - `src/Dapper.FluentMap/Compatibility/DapperIgnoredMemberMap.cs` diff --git a/.sdd/etapa-8/07-historical-regression-suite.md b/.sdd/etapa-8/07-historical-regression-suite.md new file mode 100644 index 0000000..086c8db --- /dev/null +++ b/.sdd/etapa-8/07-historical-regression-suite.md @@ -0,0 +1,91 @@ +# Etapa 8 - Historical Regression Suite + +Status: implementado no Prompt 8.6. + +## Objetivo + +Consolidar bugs historicos reais do projeto original +`henkmollema/Dapper-FluentMap` em uma suite permanente de regressao no fork, +sem criar um projeto de testes novo e sem ampliar comportamento publico alem do +que ja foi implementado na Etapa 8. + +As paginas das issues arquivadas foram revalidadas no GitHub em 2026-07-28. +Todas as issues minimas estavam fechadas no projeto original. A verificacao +confirmou que os documentos do Prompt 8.1 ainda descreviam corretamente a +categoria historica de cada bug. + +## Estrutura escolhida + +Foram criadas areas `HistoricalRegression` dentro dos projetos existentes: + +- `test/Dapper.FluentMap.Tests/HistoricalRegression/` +- `test/Dapper.FluentMap.Dommel.Tests/HistoricalRegression/` + +Nao foi criado novo projeto porque os cenarios usam infraestrutura ja existente: +SQLite in-memory, Dapper, `QueryMapped*`, generated materializer registration e +Dommel. + +## Categorias cobertas + +- Core mapping regressions: expression parsing e propriedade ignorada. +- Materialization regressions: leitura Dapper, `QueryMapped*` runtime e generated + materializer. +- Dommel regressions: resolvers, SQL gerado e materializacao por `Get`. +- Nested mapping regressions: caminhos aninhados com terminal repetido. +- Persistence behavior regressions: read-only, key nao identity, computed e + database default on insert. + +## Matriz + +| Issue | Regression test | Projeto | Status | +| ----- | --------------- | ------- | ------ | +| #94 | `ReadOnlyPropertyShouldBeMaterializedButExcludedFromWrites` | `Dapper.FluentMap.Dommel.Tests` | Covered | +| #94 | `GeneratedAndRuntimeMaterializersShouldAgreeForHistoricalReadSemantics` | `Dapper.FluentMap.Tests` | Covered | +| #114 | `PropertyNamedLikeBclMemberShouldMapExpressionProperty` | `Dapper.FluentMap.Tests` | Covered | +| #122 | `NonIdentityKeyShouldBeInsertedAndOnlyUsedForUpdateWhereClause` | `Dapper.FluentMap.Dommel.Tests` | Covered | +| #123 | `ComputedPropertyShouldBeReadButExcludedFromInsertAndUpdate` | `Dapper.FluentMap.Dommel.Tests` | Covered | +| #123 | `GeneratedAndRuntimeMaterializersShouldAgreeForHistoricalReadSemantics` | `Dapper.FluentMap.Tests` | Covered | +| #126 | `NestedMemberPathsWithSameTerminalNameShouldMaterializeDistinctValues` | `Dapper.FluentMap.Tests` | Covered | +| #126 | `GeneratedAndRuntimeMaterializersShouldAgreeForHistoricalReadSemantics` | `Dapper.FluentMap.Tests` | Covered | +| #130 | `DatabaseDefaultOnInsertShouldOmitInsertColumnAndReadDatabaseValue` | `Dapper.FluentMap.Dommel.Tests` | Covered | +| #130 | `GeneratedAndRuntimeMaterializersShouldAgreeForHistoricalReadSemantics` | `Dapper.FluentMap.Tests` | Covered | +| #133 | `IgnoredPropertySelectedByDapperShouldRemainUnmappedWithoutThrowing` | `Dapper.FluentMap.Tests` | Covered | +| #133 | `GeneratedAndRuntimeMaterializersShouldAgreeForHistoricalReadSemantics` | `Dapper.FluentMap.Tests` | Covered | + +## Prompt 8.1 + +As outras referencias diretamente relacionadas identificadas no Prompt 8.1 eram +os PRs historicos #129 e #131. + +- PR #129 fica coberto pela regressao de key nao identity da issue #122. +- PR #131 fica coberto pelas regressoes de `Ignore()` das issues #130 e #133. + +Nao foi identificada outra issue antiga, dentro do escopo de leitura versus +persistencia da Etapa 8, que justificasse novo caso alem dos cenarios minimos e +dos PRs relacionados. + +## Differential test + +`GeneratedAndRuntimeMaterializersShouldAgreeForHistoricalReadSemantics` registra +um materializer gerado via `AddGeneratedMaterializer(...)`, executa +`QueryMappedSingle()` pelo caminho generated e repete a mesma consulta pelo +fallback runtime. O teste compara: + +- read-only; +- computed; +- database-default-on-insert; +- nested paths `Rank.Level` e `Seniority.Level`; +- ignored column. + +Assim, metadata de escrita continua neutra para leitura em ambos os caminhos, e +`Ignore()` permanece a unica semantica historica que remove materializacao. + +## Bugs ainda reproduziveis + +Nenhum bug historico minimo ficou reproduzivel nos cenarios adicionados. + +O risco de compatibilidade ja documentado permanece: no Dommel, +`IsKey()` sem `SetGeneratedOption(DatabaseGeneratedOption.None)` continua +identity operacional por compatibilidade com maps antigos. A regressao historica +#122 protege explicitamente o caminho recomendado para key atribuida pela +aplicacao. diff --git a/.sdd/etapa-8/STATUS.md b/.sdd/etapa-8/STATUS.md index 322f99c..cd6747d 100644 --- a/.sdd/etapa-8/STATUS.md +++ b/.sdd/etapa-8/STATUS.md @@ -152,6 +152,30 @@ execucao de CRUD ao core. sucesso. - Inspecionados `Dapper.FluentMap.2.0.0.nupkg` e `Dapper.FluentMap.Analyzers.2.0.0.nupkg`; conteudos esperados preservados. +- Revalidadas no GitHub arquivado as issues historicas #94, #114, #122, #123, + #126, #130 e #133; todas continuam fechadas no projeto original. +- Criado `.sdd/etapa-8/07-historical-regression-suite.md` com matriz + issue/teste/projeto/status. +- Criada suite explicita em + `test/Dapper.FluentMap.Tests/HistoricalRegression/HistoricalCoreRegressionTests.cs`. +- Criada suite explicita em + `test/Dapper.FluentMap.Dommel.Tests/HistoricalRegression/DommelHistoricalRegressionTests.cs`. +- Cobertas regressions historicas de core mapping, materializacao, nested + mapping, Dommel e persistence behavior. +- Adicionado teste diferencial runtime/generated para semanticas historicas de + leitura. +- Atualizado `.sdd/etapa-8/01-historical-issues.md` com evidencia precisa dos + testes do Prompt 8.6. +- Executado `dotnet test .\test\Dapper.FluentMap.Tests\Dapper.FluentMap.Tests.csproj --configuration Release --filter "Category=HistoricalRegression"`: + sucesso, 4 testes aprovados. +- Executado `dotnet test .\test\Dapper.FluentMap.Dommel.Tests\Dapper.FluentMap.Dommel.Tests.csproj --configuration Release --filter "Category=HistoricalRegression"`: + sucesso, 4 testes aprovados apos repetir isoladamente uma tentativa paralela + que encontrou lock de build. +- Executado `dotnet restore .\Dapper.FluentMap.sln`: sucesso. +- Executado `dotnet build .\Dapper.FluentMap.sln --configuration Release --no-restore`: + sucesso, 0 warnings, 0 errors. +- Executado `dotnet test .\Dapper.FluentMap.sln --configuration Release --no-build`: + sucesso, 296 testes aprovados. ## Em andamento @@ -198,6 +222,8 @@ Nenhum apos a validacao final deste prompt. original, preservar. - Prompt 8.3 adicionou cobertura explicita para #114, #126 e #133 na semantica de leitura/materializacao. +- Prompt 8.6 consolidou a suite historica permanente com testes explicitos para + #94, #114, #122, #123, #126, #130 e #133. ## Riscos conhecidos @@ -242,7 +268,10 @@ Nenhum apos a validacao final deste prompt. - `.sdd/etapa-8/04-read-semantics.md` - `.sdd/etapa-8/05-dommel-persistence-behavior.md` - `.sdd/etapa-8/06-persistence-diagnostics.md` +- `.sdd/etapa-8/07-historical-regression-suite.md` +- `test/Dapper.FluentMap.Tests/HistoricalRegression/HistoricalCoreRegressionTests.cs` +- `test/Dapper.FluentMap.Dommel.Tests/HistoricalRegression/DommelHistoricalRegressionTests.cs` ## Ultimo prompt executado -Ultimo prompt executado: 8.5 +Ultimo prompt executado: 8.6 diff --git a/test/Dapper.FluentMap.Dommel.Tests/HistoricalRegression/DommelHistoricalRegressionTests.cs b/test/Dapper.FluentMap.Dommel.Tests/HistoricalRegression/DommelHistoricalRegressionTests.cs new file mode 100644 index 0000000..cd149a9 --- /dev/null +++ b/test/Dapper.FluentMap.Dommel.Tests/HistoricalRegression/DommelHistoricalRegressionTests.cs @@ -0,0 +1,368 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations.Schema; +using System.Linq; +using Dapper; +using Dapper.FluentMap.Dommel.Mapping; +using Dommel; +using Microsoft.Data.Sqlite; +using Xunit; + +namespace Dapper.FluentMap.Dommel.Tests.HistoricalRegression +{ + public class DommelHistoricalRegressionTests + { + [Fact] + [Trait("Category", "HistoricalRegression")] + public void ReadOnlyPropertyShouldBeMaterializedButExcludedFromWrites() + { + PreTest(); + SQLitePCL.Batteries_V2.Init(); + + FluentMapper.Initialize(configuration => + { + configuration.AddMap(new ReadOnlyEntityMap()); + configuration.ForDommel(); + }); + + using (var connection = OpenConnection()) + { + connection.Execute(@" +CREATE TABLE historical_readonly_entities ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + read_only_value TEXT DEFAULT 'server-read' +);"); + + var logs = CaptureDommelLogs(); + try + { + // Historical issue #94. + var entity = new ReadOnlyEntity + { + Name = "inserted", + ReadOnlyValue = "client-insert" + }; + + var id = Convert.ToInt32(connection.Insert(entity)); + var inserted = connection.Get(id); + + Assert.Equal("inserted", inserted.Name); + Assert.Equal("server-read", inserted.ReadOnlyValue); + + var insertSql = LastSql(logs, "insert into"); + AssertSqlContains(insertSql, "name"); + AssertSqlDoesNotContain(insertSql, "read_only_value"); + + entity.Id = id; + entity.Name = "updated"; + entity.ReadOnlyValue = "client-update"; + + Assert.True(connection.Update(entity)); + + var updated = connection.Get(id); + Assert.Equal("updated", updated.Name); + Assert.Equal("server-read", updated.ReadOnlyValue); + + var updateSql = LastSql(logs, "update "); + AssertSqlContains(updateSql, "name", "where", "id"); + AssertSqlDoesNotContain(updateSql, "read_only_value"); + } + finally + { + DommelMapper.LogReceived = null; + } + } + } + + [Fact] + [Trait("Category", "HistoricalRegression")] + public void NonIdentityKeyShouldBeInsertedAndOnlyUsedForUpdateWhereClause() + { + PreTest(); + SQLitePCL.Batteries_V2.Init(); + + FluentMapper.Initialize(configuration => + { + configuration.AddMap(new NonIdentityKeyEntityMap()); + configuration.ForDommel(); + }); + + using (var connection = OpenConnection()) + { + connection.Execute(@" +CREATE TABLE historical_assigned_key_entities ( + code TEXT PRIMARY KEY, + name TEXT NOT NULL +);"); + + var logs = CaptureDommelLogs(); + try + { + // Historical issue #122. + var entity = new NonIdentityKeyEntity + { + Code = "A-001", + Name = "inserted" + }; + + connection.Insert(entity); + + var inserted = connection.QuerySingle( + "SELECT name FROM historical_assigned_key_entities WHERE code = 'A-001';"); + + Assert.Equal("inserted", inserted); + + var insertSql = LastSql(logs, "insert into"); + AssertSqlContains(insertSql, "code", "name"); + + entity.Name = "updated"; + Assert.True(connection.Update(entity)); + + var updated = connection.QuerySingle( + "SELECT name FROM historical_assigned_key_entities WHERE code = 'A-001';"); + + Assert.Equal("updated", updated); + + var updateSql = LastSql(logs, "update "); + AssertSqlContains(updateSql, "name", "where", "code"); + Assert.DoesNotContain("set \"code\"", updateSql, StringComparison.OrdinalIgnoreCase); + } + finally + { + DommelMapper.LogReceived = null; + } + } + } + + [Fact] + [Trait("Category", "HistoricalRegression")] + public void ComputedPropertyShouldBeReadButExcludedFromInsertAndUpdate() + { + PreTest(); + SQLitePCL.Batteries_V2.Init(); + + FluentMapper.Initialize(configuration => + { + configuration.AddMap(new ComputedEntityMap()); + configuration.ForDommel(); + }); + + using (var connection = OpenConnection()) + { + connection.Execute(@" +CREATE TABLE historical_computed_entities ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + computed_value TEXT GENERATED ALWAYS AS (name || '-computed') STORED +);"); + + var logs = CaptureDommelLogs(); + try + { + // Historical issue #123. + var entity = new ComputedEntity + { + Name = "inserted", + ComputedValue = "client-computed" + }; + + var id = Convert.ToInt32(connection.Insert(entity)); + var inserted = connection.Get(id); + + Assert.Equal("inserted-computed", inserted.ComputedValue); + + var insertSql = LastSql(logs, "insert into"); + AssertSqlContains(insertSql, "name"); + AssertSqlDoesNotContain(insertSql, "computed_value"); + + entity.Id = id; + entity.Name = "updated"; + entity.ComputedValue = "client-update"; + + Assert.True(connection.Update(entity)); + + var updated = connection.Get(id); + Assert.Equal("updated-computed", updated.ComputedValue); + + var updateSql = LastSql(logs, "update "); + AssertSqlContains(updateSql, "name"); + AssertSqlDoesNotContain(updateSql, "computed_value"); + } + finally + { + DommelMapper.LogReceived = null; + } + } + } + + [Fact] + [Trait("Category", "HistoricalRegression")] + public void DatabaseDefaultOnInsertShouldOmitInsertColumnAndReadDatabaseValue() + { + PreTest(); + SQLitePCL.Batteries_V2.Init(); + + FluentMapper.Initialize(configuration => + { + configuration.AddMap(new DefaultValueEntityMap()); + configuration.ForDommel(); + }); + + using (var connection = OpenConnection()) + { + connection.Execute(@" +CREATE TABLE historical_default_entities ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + created_at TEXT DEFAULT '2026-07-28 09:30:00' +);"); + + var logs = CaptureDommelLogs(); + try + { + // Historical issue #130. + var entity = new DefaultValueEntity + { + Name = "inserted", + CreatedAt = new DateTime(2000, 1, 1) + }; + + var id = Convert.ToInt32(connection.Insert(entity)); + var loaded = connection.Get(id); + + Assert.Equal("inserted", loaded.Name); + Assert.Equal(new DateTime(2026, 7, 28, 9, 30, 0), loaded.CreatedAt); + Assert.NotEqual(default, loaded.CreatedAt); + + var insertSql = LastSql(logs, "insert into"); + AssertSqlContains(insertSql, "name"); + AssertSqlDoesNotContain(insertSql, "created_at"); + } + finally + { + DommelMapper.LogReceived = null; + } + } + } + + private static void PreTest() + { + FluentMapper.EntityMaps.Clear(); + FluentMapper.TypeConventions.Clear(); + DommelMapper.LogReceived = null; + } + + private static SqliteConnection OpenConnection() + { + var connection = new SqliteConnection("Data Source=:memory:"); + connection.Open(); + return connection; + } + + private static List CaptureDommelLogs() + { + var logs = new List(); + DommelMapper.LogReceived = logs.Add; + return logs; + } + + private static string LastSql(List logs, string fragment) + { + return logs.Last(log => log.IndexOf(fragment, StringComparison.OrdinalIgnoreCase) >= 0); + } + + private static void AssertSqlContains(string sql, params string[] fragments) + { + foreach (var fragment in fragments) + { + Assert.Contains(fragment, sql, StringComparison.OrdinalIgnoreCase); + } + } + + private static void AssertSqlDoesNotContain(string sql, params string[] fragments) + { + foreach (var fragment in fragments) + { + Assert.DoesNotContain(fragment, sql, StringComparison.OrdinalIgnoreCase); + } + } + + private sealed class ReadOnlyEntity + { + public int Id { get; set; } + + public string Name { get; set; } + + public string ReadOnlyValue { get; set; } + } + + private sealed class ReadOnlyEntityMap : DommelEntityMap + { + public ReadOnlyEntityMap() + { + ToTable("historical_readonly_entities"); + Map(entity => entity.Id).ToColumn("id").IsIdentity(); + Map(entity => entity.Name).ToColumn("name"); + Map(entity => entity.ReadOnlyValue).ToColumn("read_only_value").ReadOnly(); + } + } + + private sealed class NonIdentityKeyEntity + { + public string Code { get; set; } + + public string Name { get; set; } + } + + private sealed class NonIdentityKeyEntityMap : DommelEntityMap + { + public NonIdentityKeyEntityMap() + { + ToTable("historical_assigned_key_entities"); + Map(entity => entity.Code).ToColumn("code").IsKey().SetGeneratedOption(DatabaseGeneratedOption.None); + Map(entity => entity.Name).ToColumn("name"); + } + } + + private sealed class ComputedEntity + { + public int Id { get; set; } + + public string Name { get; set; } + + public string ComputedValue { get; set; } + } + + private sealed class ComputedEntityMap : DommelEntityMap + { + public ComputedEntityMap() + { + ToTable("historical_computed_entities"); + Map(entity => entity.Id).ToColumn("id").IsIdentity(); + Map(entity => entity.Name).ToColumn("name"); + Map(entity => entity.ComputedValue).ToColumn("computed_value").SetGeneratedOption(DatabaseGeneratedOption.Computed); + } + } + + private sealed class DefaultValueEntity + { + public int Id { get; set; } + + public string Name { get; set; } + + public DateTime CreatedAt { get; set; } + } + + private sealed class DefaultValueEntityMap : DommelEntityMap + { + public DefaultValueEntityMap() + { + ToTable("historical_default_entities"); + Map(entity => entity.Id).ToColumn("id").IsIdentity(); + Map(entity => entity.Name).ToColumn("name"); + Map(entity => entity.CreatedAt).ToColumn("created_at").DatabaseDefaultOnInsert(); + } + } + } +} diff --git a/test/Dapper.FluentMap.Tests/HistoricalRegression/HistoricalCoreRegressionTests.cs b/test/Dapper.FluentMap.Tests/HistoricalRegression/HistoricalCoreRegressionTests.cs new file mode 100644 index 0000000..8d03d1c --- /dev/null +++ b/test/Dapper.FluentMap.Tests/HistoricalRegression/HistoricalCoreRegressionTests.cs @@ -0,0 +1,253 @@ +using System; +using Dapper; +using Dapper.FluentMap.Mapping; +using Dapper.FluentMap.Materialization; +using Microsoft.Data.Sqlite; +using Xunit; + +namespace Dapper.FluentMap.Tests.HistoricalRegression +{ + public class HistoricalCoreRegressionTests + { + [Fact] + [Trait("Category", "HistoricalRegression")] + public void PropertyNamedLikeBclMemberShouldMapExpressionProperty() + { + ResetMapper(typeof(MemberCollisionEntity)); + + try + { + // Historical issue #114. + FluentMapper.Initialize(configuration => configuration.AddMap(new MemberCollisionMap())); + + using (var connection = OpenConnection()) + { + var entity = connection.QuerySingle( + "SELECT 'markdown' AS format_text;"); + + Assert.Equal("markdown", entity.Format); + } + } + finally + { + ResetMapper(typeof(MemberCollisionEntity)); + } + } + + [Fact] + [Trait("Category", "HistoricalRegression")] + public void IgnoredPropertySelectedByDapperShouldRemainUnmappedWithoutThrowing() + { + ResetMapper(typeof(IgnoredColumnEntity)); + + try + { + // Historical issue #133. + FluentMapper.Initialize(configuration => configuration.AddMap(new IgnoredColumnMap())); + + using (var connection = OpenConnection()) + { + var entity = connection.QuerySingle( + "SELECT 7 AS id, 'server-secret' AS secret;"); + + Assert.Equal(7, entity.Id); + Assert.Equal("initial", entity.Secret); + } + } + finally + { + ResetMapper(typeof(IgnoredColumnEntity)); + } + } + + [Fact] + [Trait("Category", "HistoricalRegression")] + public void NestedMemberPathsWithSameTerminalNameShouldMaterializeDistinctValues() + { + ResetMapper(typeof(NestedLevelEntity)); + + try + { + // Historical issue #126. + FluentMapper.Initialize(configuration => configuration.AddMap(new NestedLevelMap())); + + using (var connection = OpenConnection()) + { + var entity = connection.QueryMappedSingle( + "SELECT 10 AS rank_level, 20 AS seniority_level, 30 AS completed_profile_level;"); + + Assert.NotNull(entity.Rank); + Assert.NotNull(entity.Seniority); + Assert.NotNull(entity.CompletedProfile); + Assert.Equal(10, entity.Rank.Level); + Assert.Equal(20, entity.Seniority.Level); + Assert.Equal(30, entity.CompletedProfile.Level); + } + } + finally + { + ResetMapper(typeof(NestedLevelEntity)); + } + } + + [Fact] + [Trait("Category", "HistoricalRegression")] + public void GeneratedAndRuntimeMaterializersShouldAgreeForHistoricalReadSemantics() + { + // Historical issues #94, #123, #126, #130 and #133. + var generated = QueryHistoricalReadEntity(useGeneratedMaterializer: true); + var runtime = QueryHistoricalReadEntity(useGeneratedMaterializer: false); + + Assert.Equal(runtime.Id, generated.Id); + Assert.Equal(runtime.ReadOnlyValue, generated.ReadOnlyValue); + Assert.Equal(runtime.ComputedValue, generated.ComputedValue); + Assert.Equal(runtime.CreatedAt, generated.CreatedAt); + Assert.Equal(runtime.Rank.Level, generated.Rank.Level); + Assert.Equal(runtime.Seniority.Level, generated.Seniority.Level); + Assert.Equal(runtime.Secret, generated.Secret); + } + + private static HistoricalReadEntity QueryHistoricalReadEntity(bool useGeneratedMaterializer) + { + ResetMapper(typeof(HistoricalReadEntity)); + + try + { + FluentMapper.Initialize(configuration => + { + configuration.AddMap(new HistoricalReadMap()); + + if (useGeneratedMaterializer) + { + configuration.AddGeneratedMaterializer( + new[] + { + GeneratedMaterializerColumn.Map("id", nameof(HistoricalReadEntity.Id)), + GeneratedMaterializerColumn.Map("read_only_value", nameof(HistoricalReadEntity.ReadOnlyValue)), + GeneratedMaterializerColumn.Map("computed_value", nameof(HistoricalReadEntity.ComputedValue)), + GeneratedMaterializerColumn.Map("created_at", nameof(HistoricalReadEntity.CreatedAt)), + GeneratedMaterializerColumn.Map("rank_level", "Rank.Level"), + GeneratedMaterializerColumn.Map("seniority_level", "Seniority.Level"), + GeneratedMaterializerColumn.Ignore("secret") + }, + record => new HistoricalReadEntity + { + Id = Convert.ToInt32(record.GetValue(0)), + ReadOnlyValue = Convert.ToString(record.GetValue(1)), + ComputedValue = Convert.ToString(record.GetValue(2)), + CreatedAt = Convert.ToString(record.GetValue(3)), + Rank = new HistoricalLevel { Level = Convert.ToInt32(record.GetValue(4)) }, + Seniority = new HistoricalLevel { Level = Convert.ToInt32(record.GetValue(5)) } + }); + } + }); + + using (var connection = OpenConnection()) + { + return connection.QueryMappedSingle( + "SELECT 1 AS id, 'read' AS read_only_value, 'computed' AS computed_value, '2026-07-28' AS created_at, 5 AS rank_level, 9 AS seniority_level, 'server-secret' AS secret;"); + } + } + finally + { + ResetMapper(typeof(HistoricalReadEntity)); + } + } + + 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 MemberCollisionEntity + { + public string Format { get; set; } + } + + private sealed class MemberCollisionMap : EntityMap + { + public MemberCollisionMap() + { + Map(entity => entity.Format).ToColumn("format_text"); + } + } + + private sealed class IgnoredColumnEntity + { + public int Id { get; set; } + + public string Secret { get; set; } = "initial"; + } + + private sealed class IgnoredColumnMap : EntityMap + { + public IgnoredColumnMap() + { + Map(entity => entity.Id).ToColumn("id"); + Map(entity => entity.Secret).ToColumn("secret").Ignore(); + } + } + + private sealed class NestedLevelEntity + { + public HistoricalLevel Rank { get; set; } + + public HistoricalLevel Seniority { get; set; } + + public HistoricalLevel CompletedProfile { get; set; } + } + + private sealed class NestedLevelMap : EntityMap + { + public NestedLevelMap() + { + Map(entity => entity.Rank.Level).ToColumn("rank_level"); + Map(entity => entity.Seniority.Level).ToColumn("seniority_level"); + Map(entity => entity.CompletedProfile.Level).ToColumn("completed_profile_level"); + } + } + + private sealed class HistoricalReadEntity + { + public int Id { get; set; } + + public string ReadOnlyValue { get; set; } + + public string ComputedValue { get; set; } + + public string CreatedAt { get; set; } + + public HistoricalLevel Rank { get; set; } + + public HistoricalLevel Seniority { get; set; } + + public string Secret { get; set; } = "initial"; + } + + private sealed class HistoricalReadMap : EntityMap + { + public HistoricalReadMap() + { + Map(entity => entity.Id).ToColumn("id"); + Map(entity => entity.ReadOnlyValue).ToColumn("read_only_value").ReadOnly(); + Map(entity => entity.ComputedValue).ToColumn("computed_value").Computed(); + Map(entity => entity.CreatedAt).ToColumn("created_at").DatabaseDefaultOnInsert(); + Map(entity => entity.Rank.Level).ToColumn("rank_level"); + Map(entity => entity.Seniority.Level).ToColumn("seniority_level"); + Map(entity => entity.Secret).ToColumn("secret").Ignore(); + } + } + + private sealed class HistoricalLevel + { + public int Level { get; set; } + } + } +} From d19eac205ceb812b123829eec0dc343462ad3517 Mon Sep 17 00:00:00 2001 From: Rodrigo de Oliveira Date: Tue, 28 Jul 2026 14:20:42 -0300 Subject: [PATCH 14/49] fix(mapping): harden persistence behavior compatibility --- .sdd/etapa-8/08-compatibility-notes.md | 121 ++++++++++ .sdd/etapa-8/FINAL-REPORT.md | 211 ++++++++++++++++++ .sdd/etapa-8/STATUS.md | 37 ++- README.md | 177 +++++++++++++-- .../Mapping/PropertyPersistenceMetadata.cs | 5 + .../FluentMapConfigurationAnalyzerTests.cs | 25 +++ .../PropertyPersistenceMetadataTests.cs | 17 ++ 7 files changed, 577 insertions(+), 16 deletions(-) create mode 100644 .sdd/etapa-8/08-compatibility-notes.md create mode 100644 .sdd/etapa-8/FINAL-REPORT.md diff --git a/.sdd/etapa-8/08-compatibility-notes.md b/.sdd/etapa-8/08-compatibility-notes.md new file mode 100644 index 0000000..1d94ea8 --- /dev/null +++ b/.sdd/etapa-8/08-compatibility-notes.md @@ -0,0 +1,121 @@ +# Etapa 8 - Compatibility Notes + +## Objetivo + +Orientar usuarios do FluentMap historico na migracao de usos ambigues de +`Ignore()` e metadata Dommel para persistence behaviors explicitos, sem exigir +redesign de maps existentes. + +## Regra principal + +`Ignore()` continua sendo uma semantica de leitura e escrita: + +```text +Read = no +Insert = no +Update = no +``` + +Use `Ignore()` somente quando a propriedade nao deve ser materializada pelo +FluentMap. + +## Migracoes comuns + +### Valor read-only do banco + +Antes: + +```csharp +Map(entity => entity.ServerValue) + .ToColumn("server_value") + .Ignore(); +``` + +Agora: + +```csharp +Map(entity => entity.ServerValue) + .ToColumn("server_value") + .ReadOnly(); +``` + +Resultado: + +```text +SELECT: participa +INSERT: excluido +UPDATE: excluido +``` + +### Default aplicado no insert + +Antes: + +```csharp +Map(entity => entity.CreatedAt) + .ToColumn("created_at") + .Ignore(); +``` + +Agora: + +```csharp +Map(entity => entity.CreatedAt) + .ToColumn("created_at") + .DatabaseDefaultOnInsert(); +``` + +Use `.ExcludeFromUpdate()` junto se o valor tambem nao deve ser alterado depois +do insert. + +### Coluna computed + +Antes: + +```csharp +Map(entity => entity.Total) + .ToColumn("total") + .SetGeneratedOption(DatabaseGeneratedOption.Computed); +``` + +Agora, quando estiver usando a API nova: + +```csharp +Map(entity => entity.Total) + .ToColumn("total") + .Computed(); +``` + +A API historica `SetGeneratedOption(DatabaseGeneratedOption.Computed)` continua +suportada no pacote Dommel e passa a alimentar a mesma metadata de persistencia. + +### Key atribuida pela aplicacao + +Antes, usar apenas `IsKey()` podia ser interpretado como identity operacional no +Dommel historico. + +Agora: + +```csharp +Map(entity => entity.Code) + .ToColumn("code") + .IsKey() + .SetGeneratedOption(DatabaseGeneratedOption.None); +``` + +Esse e o caminho compativel para key non-identity: participa do `INSERT`, nao +entra no `SET` de `UPDATE` e e usada no `WHERE`. + +## Compatibilidade preservada + +- `IPropertyMap` nao foi alterada. +- `Ignore()` nao mudou de significado. +- APIs Dommel historicas `IsKey()`, `IsIdentity()` e `SetGeneratedOption(...)` + continuam disponiveis. +- `IsKey()` sem `SetGeneratedOption(DatabaseGeneratedOption.None)` preserva o + comportamento operacional legado de identity no resolver Dommel. + +## Limites + +O core descreve metadata, mas nao gera SQL e nao adiciona CRUD. A traducao para +`INSERT` e `UPDATE` acontece no pacote `Dapper.FluentMap.Dommel`. diff --git a/.sdd/etapa-8/FINAL-REPORT.md b/.sdd/etapa-8/FINAL-REPORT.md new file mode 100644 index 0000000..d08239d --- /dev/null +++ b/.sdd/etapa-8/FINAL-REPORT.md @@ -0,0 +1,211 @@ +# Etapa 8 - Final Report + +## Objetivo + +Encerrar a Etapa 8 - Persistence Semantics & Historical Compatibility com +auditoria da especificacao, hardening de API, documentacao publica, suite de +regressao historica e validacao completa da solution. + +## Implementado + +- Metadata publica aditiva de persistencia no core: + `PropertyPersistenceMetadata` e `IPropertyMapWithPersistenceMetadata`. +- APIs fluent em `PropertyMapBase`: + `ExcludeFromInsert()`, `ExcludeFromUpdate()`, `ReadOnly()`, `Computed()` e + `DatabaseDefaultOnInsert()`. +- `Ignore()` preservado como semantica historica de nao materializar e nao + persistir. +- `MemberMappingExplanation.Persistence` exposto em `Explain()` e + `Explain()`. +- Runtime validation de invariants de persistence metadata. +- Analyzer `DFM012` para combinacoes contraditorias em fluent chains diretas. +- Source generator atualizado para tratar write metadata como neutra para + materializacao gerada. +- Integracao Dommel consumindo metadata para `INSERT`, `UPDATE`, keys, + identities, computed columns e defaults de banco. +- Documentacao publica do README atualizada em ingles e portugues. +- Notas de compatibilidade historica em `08-compatibility-notes.md`. +- Hardening final do Prompt 8.7: `DatabaseDefaultOnInsert().Computed()` agora + falha como combinacao contraditoria, igual a ordem inversa. + +## Persistence Semantics + +| Requirement | Implementation | Tests | Status | Notes | +| --- | --- | --- | --- | --- | +| Separar leitura, insert e update | `PropertyPersistenceMetadata` com `ParticipatesInMaterialization`, `ParticipatesInInsert` e `ParticipatesInUpdate` | `PropertyPersistenceMetadataTests`, `ConfigurationValidationTests` | Concluido | Core descreve metadata, nao gera SQL. | +| Preservar `Ignore()` historico | `Ignore()` seta `PropertyPersistenceMetadata.Ignored` e continua afetando materializacao | `IgnoredPropertySelectedByDapperShouldRemainUnmappedWithoutThrowing`, generated/runtime regressions | Concluido | `Ignore()` nao foi reaproveitado para read-only. | +| Read-only materializa mas nao escreve | `ReadOnly()` preserva read e exclui insert/update | Core metadata, Dommel SQLite, historical regressions | Concluido | Resolve a lacuna central de #94. | +| Excluir apenas insert | `ExcludeFromInsert()` preserva update e read | Metadata tests, Dommel integration | Concluido | Dommel recompoe colunas de insert via SQL builder wrapper. | +| Excluir apenas update | `ExcludeFromUpdate()` preserva insert e read | Metadata tests, Dommel integration | Concluido | Dommel traduz para `ColumnPropertyInfo.IsGenerated` no caminho de update. | +| Database default on insert | `DatabaseDefaultOnInsert()` marca generated/default, omite insert e preserva update | `DatabaseDefaultOnInsertShouldOmitInsertColumnAndReadDatabaseValue` | Concluido | Caso documentado com `created_at DEFAULT ...`, sem provider especifico no README. | +| Computed | `Computed()` marca generated/computed e omite insert/update | Dommel integration e historical regression #123 | Concluido | Hardening 8.7 cobre as duas ordens com database default. | +| Key vs identity | Dommel metadata diferencia `IsKey()`, `IsIdentity()` e `SetGeneratedOption(None)` | Non-identity key e composite key tests | Concluido com compatibilidade | `IsKey()` sem generated option preserva comportamento operacional legado. | +| Generated materializers ignoram write metadata | Generator aceita chamadas read-neutral e runtime valida apenas semantica de leitura | Generator tests e generated registration integration | Concluido | Apenas `Ignore()` altera read descriptor. | +| Diagnostics conservadores | `DFM012` e runtime validation | Analyzer tests e configuration validation tests | Concluido | Analyzer nao infere fluxo dinamico por design. | +| Dommel consome metadata | Resolvers e `DommelPersistenceSqlBuilder` | 21 testes Dommel, SQLite real | Concluido | Builders customizados posteriores a `ForDommel()` precisam honrar metadata. | +| Documentacao publica | README EN/PT e compatibility notes | Revisao manual | Concluido | Sem documentar CRUD no core. | + +## API adicionada + +Core: + +- `Dapper.FluentMap.Mapping.PropertyPersistenceMetadata`; +- `Dapper.FluentMap.Mapping.IPropertyMapWithPersistenceMetadata`; +- `PropertyMapBase.Persistence`; +- `PropertyMapBase.ExcludeFromInsert()`; +- `PropertyMapBase.ExcludeFromUpdate()`; +- `PropertyMapBase.ReadOnly()`; +- `PropertyMapBase.Computed()`; +- `PropertyMapBase.DatabaseDefaultOnInsert()`; +- `MemberMappingExplanation.Persistence`. + +Dommel existente preservado e conectado a metadata: + +- `DommelPropertyMap.IsKey()`; +- `DommelPropertyMap.IsIdentity()`; +- `DommelPropertyMap.SetGeneratedOption(DatabaseGeneratedOption option)`. + +Revisao de consistencia: + +- `IPropertyMap` nao foi alterada. +- A API nova e aditiva. +- Nao ha API de CRUD, SQL generator, DI, converters ou configuration instances + no core. +- Nao foi identificado vazamento de Dommel para o core; key/identity continuam + APIs publicas do pacote Dommel. +- A combinacao contraditoria `DatabaseDefaultOnInsert().Computed()` foi corrigida + no Prompt 8.7. + +## Dommel Integration + +Dommel honra os behaviors para comandos gerados: + +| Behavior | SELECT | INSERT | UPDATE | +| --- | --- | --- | --- | +| Normal | Sim | Sim | Sim | +| Ignore | Nao | Nao | Nao | +| ReadOnly | Sim | Nao | Nao | +| ExcludeFromInsert | Sim | Nao | Sim | +| ExcludeFromUpdate | Sim | Sim | Nao | +| DatabaseDefaultOnInsert | Sim | Nao | Sim | +| DatabaseDefaultOnInsert + ExcludeFromUpdate | Sim | Nao | Nao | +| Computed | Sim | Nao | Nao | +| Identity key | Sim | Nao | WHERE only | +| Non-identity key | Sim | Sim | WHERE only | + +`Dapper.FluentMap.Dommel` envolve os SQL builders padrao do Dommel para recompor +as colunas de insert com base em `ParticipatesInInsert`. Para update, o resolver +traduz `ParticipatesInUpdate=false` para o contrato publico de generated columns +do Dommel. + +## Historical Issues + +| Issue | Status | Evidencia | +| --- | --- | --- | +| #94 ReadOnly Fields | Resolved by implementation; Regression covered | `ReadOnly()` + `DommelHistoricalRegressionTests.ReadOnlyPropertyShouldBeMaterializedButExcludedFromWrites`. | +| #114 Conflict between property and type members | Already fixed upstream; Regression covered | `ReflectionHelper` usa o `MemberInfo` real; `PropertyNamedLikeBclMemberShouldMapExpressionProperty`. | +| #122 Insert issue when key column is not identity | Resolved by architecture; Regression covered | Key e identity separados; `NonIdentityKeyShouldBeInsertedAndOnlyUsedForUpdateWhereClause`. | +| #123 Computed property used in insert/update | Resolved by implementation; Regression covered | `Computed()` e `SetGeneratedOption(Computed)` omitidos de insert/update; computed SQLite real. | +| #126 Nested properties ending with same name | Resolved by architecture; Regression covered | `MemberPath` completo; nested/generated regressions com `Rank.Level` e `Seniority.Level`. | +| #130 Default value do banco vs `Ignore()` | Resolved by implementation; Regression covered | `DatabaseDefaultOnInsert()` com coluna `created_at DEFAULT ...`. | +| #133 `Ignore()` causing `NotImplementedException` | Already fixed upstream; Regression covered | `DapperIgnoredMemberMap`; Dapper query selecionando coluna ignorada sem throw. | + +## Regression Coverage + +- Core metadata defaults, read-only, computed, database default, ignore, + exclusoes por operacao, inherited maps e profiles. +- Runtime validation de metadata efetiva e maps customizados invalidos. +- Analyzer `DFM012` para combinacoes invalidas nas duas ordens relevantes. +- Dapper normal mapping para `Ignore()` e propriedades com nomes conflitantes. +- `QueryMapped*` runtime e generated materializer para read semantics. +- Dommel SQLite real para insert, update, select, defaults, computed columns, + identities, non-identity keys e composite keys. +- Historical regression suite dedicada para #94, #114, #122, #123, #126, #130 e + #133. + +## Backward Compatibility + +- Sem remocao de APIs publicas existentes. +- `IPropertyMap` preservada, evitando breaking change binario para + implementacoes customizadas. +- `Ignore()` preserva comportamento historico de leitura. +- `Dapper.Query()`, `QueryMapped*`, profiles e generated materializers nao + usam metadata de insert/update para decidir materializacao. +- `IsKey()` sem `SetGeneratedOption(DatabaseGeneratedOption.None)` continua + identity operacional no Dommel para compatibilidade. +- O repositorio nao possui ferramenta dedicada de API/binary compatibility; a + revisao foi feita por diff da superficie publica e build/pack Release. + +## Breaking Changes + +Nenhuma breaking change intencional foi introduzida. + +Correcoes de bug podem alterar configuracoes contraditorias que antes eram +aceitas por acidente. No Prompt 8.7, `DatabaseDefaultOnInsert().Computed()` passa +a falhar com `FluentMapConfigurationException`, alinhado a especificacao e ao +analyzer. + +## Known Limitations + +- O core nao gera SQL e nao adiciona CRUD. +- Dommel custom SQL builders registrados depois de `ForDommel()` substituem o + wrapper de insert e devem honrar `ParticipatesInInsert` por conta propria. +- Dommel trabalha com propriedades flat; nested materialization continua sendo + responsabilidade de `QueryMapped*`. +- Analyzer `DFM012` cobre apenas fluent chains estaticamente visiveis; cenarios + dinamicos dependem de runtime validation. +- `QueryMapped*` permanece buffered e nao oferece streaming. +- Native AOT completo nao foi declarado nesta etapa. + +## Technical Debt + +- Avaliar uma ferramenta formal de API/binary compatibility antes de release + publica maior. +- Investigar uma estrategia menos global para caches/resolvers Dommel em etapa + dedicada, sem quebrar o modelo atual de startup. +- Melhorar diagnostico amigavel para persistence metadata em logs se houver + demanda de usuarios. +- Revisar provider-specific SQL builders Dommel alem de SQLite quando houver + matriz de CI ou demanda real. + +## Deferred Items + +- `QueryMultipleMapped`. +- Streaming e `IAsyncEnumerable`. +- Property converters. +- DI e configuration instances. +- CRUD ou SQL generator no core. +- Provider-specific hardening amplo de Dommel. +- Compatibilidade Native AOT alem dos cenarios ja validados em etapas anteriores. + +## Recommendations for Etapa 9 + +- Manter o escopo de Etapa 9 separado de persistence semantics. +- Se Etapa 9 tocar materializacao, preservar a regra: write metadata nao altera + leitura, exceto `Ignore()`. +- Antes de ampliar Dommel, decidir se a limitacao de builders customizados deve + virar API publica, documentacao adicional ou teste provider-specific. +- Considerar API compatibility tooling antes de preparar release NuGet. + +## Validation + +Executado em 2026-07-28: + +```bash +dotnet restore ./Dapper.FluentMap.sln +dotnet build ./Dapper.FluentMap.sln --configuration Release --no-restore +dotnet test ./Dapper.FluentMap.sln --configuration Release --no-build +dotnet test ./test/Dapper.FluentMap.Dommel.Tests/Dapper.FluentMap.Dommel.Tests.csproj --configuration Release --no-build +dotnet pack ./src/Dapper.FluentMap/Dapper.FluentMap.csproj --configuration Release --no-build --output ./artifacts/packages +``` + +Resultados: + +- Restore: sucesso. +- Build Release: sucesso, 0 warnings, 0 errors. +- Solution tests: sucesso, 298 testes aprovados. +- Dommel tests isolados: sucesso, 21 testes aprovados. +- Pack core: sucesso; warning legado `NU5125` sobre `PackageLicenseUrl` / + `licenseUrl`. +- Pacote inspecionado: `lib/netstandard2.0/Dapper.FluentMap.dll` e + `lib/netstandard2.0/Dapper.FluentMap.xml` presentes. diff --git a/.sdd/etapa-8/STATUS.md b/.sdd/etapa-8/STATUS.md index cd6747d..f3bf6ab 100644 --- a/.sdd/etapa-8/STATUS.md +++ b/.sdd/etapa-8/STATUS.md @@ -1,5 +1,9 @@ # Etapa 8 Status +Status: Concluída + +Último prompt executado: 8.7 + ## Objetivo Definir e implementar o modelo inicial de metadata de persistencia de @@ -176,10 +180,37 @@ execucao de CRUD ao core. sucesso, 0 warnings, 0 errors. - Executado `dotnet test .\Dapper.FluentMap.sln --configuration Release --no-build`: sucesso, 296 testes aprovados. +- Executado Prompt 8.7 de hardening e fechamento. +- Corrigida combinacao contraditoria `DatabaseDefaultOnInsert().Computed()` para + falhar com `FluentMapConfigurationException`. +- Adicionada regressao runtime para `DatabaseDefaultOnInsert().Computed()`. +- Adicionada regressao do analyzer `DFM012` para + `DatabaseDefaultOnInsert().Computed()`. +- Atualizado `README.md` com documentacao publica detalhando `Ignore`, + read-only, database defaults, computed, keys e integracao Dommel em ingles e + portugues. +- Criado `.sdd/etapa-8/08-compatibility-notes.md`. +- Criado `.sdd/etapa-8/FINAL-REPORT.md`. +- Executado `dotnet test .\test\Dapper.FluentMap.Tests\Dapper.FluentMap.Tests.csproj --configuration Release --filter "FullyQualifiedName~PropertyPersistenceMetadataTests"`: + sucesso, 15 testes aprovados. +- Executado `dotnet test .\test\Dapper.FluentMap.Analyzers.Tests\Dapper.FluentMap.Analyzers.Tests.csproj --configuration Release --filter "FullyQualifiedName~FluentMapConfigurationAnalyzerTests"`: + sucesso, 15 testes aprovados. +- Executado `dotnet restore .\Dapper.FluentMap.sln`: sucesso. +- Executado `dotnet build .\Dapper.FluentMap.sln --configuration Release --no-restore`: + sucesso, 0 warnings, 0 errors. +- Executado `dotnet test .\Dapper.FluentMap.sln --configuration Release --no-build`: + sucesso, 298 testes aprovados. +- Executado `dotnet test .\test\Dapper.FluentMap.Dommel.Tests\Dapper.FluentMap.Dommel.Tests.csproj --configuration Release --no-build`: + sucesso, 21 testes aprovados. +- Executado `dotnet pack .\src\Dapper.FluentMap\Dapper.FluentMap.csproj --configuration Release --no-build --output .\artifacts\packages`: + sucesso; warning legado `NU5125` sobre `PackageLicenseUrl`/`licenseUrl`. +- Inspecionado `artifacts/packages/Dapper.FluentMap.2.0.0.nupkg`: + contem `lib/netstandard2.0/Dapper.FluentMap.dll` e + `lib/netstandard2.0/Dapper.FluentMap.xml`. ## Em andamento -Nenhum apos a validacao final deste prompt. +Nenhum. Etapa 8 concluida. ## Proximos passos @@ -272,6 +303,6 @@ Nenhum apos a validacao final deste prompt. - `test/Dapper.FluentMap.Tests/HistoricalRegression/HistoricalCoreRegressionTests.cs` - `test/Dapper.FluentMap.Dommel.Tests/HistoricalRegression/DommelHistoricalRegressionTests.cs` -## Ultimo prompt executado +## Último prompt executado -Ultimo prompt executado: 8.6 +Último prompt executado: 8.7 diff --git a/README.md b/README.md index d2521e7..3a297b7 100644 --- a/README.md +++ b/README.md @@ -108,7 +108,56 @@ Map(product => product.Total) .Computed(); ``` -`Ignore()` keeps its historical meaning: the property does not participate in FluentMap materialization or generated persistence metadata. `ReadOnly()` still allows materialization, but excludes the property from INSERT and UPDATE metadata. +### Ignore + +`Ignore()` keeps its historical meaning: the property does not participate in FluentMap materialization or generated persistence metadata. + +```csharp +Map(product => product.TransientValue) + .Ignore(); +``` + +Do not use `Ignore()` for database values that should still be selected. It is not the same as read-only persistence metadata. + +### Read-only + +Use `ReadOnly()` for database values that are selected but not written by generated persistence operations: + +```csharp +Map(product => product.UpdatedAt) + .ToColumn("updated_at") + .ReadOnly(); +``` + +```text +SELECT: participates +INSERT: excluded +UPDATE: excluded +``` + +### Database Defaults + +Use `DatabaseDefaultOnInsert()` when the database supplies the initial value if the column is omitted from `INSERT`, for example a `created_at DEFAULT ...` column: + +```csharp +Map(product => product.CreatedAt) + .ToColumn("created_at") + .DatabaseDefaultOnInsert(); +``` + +This excludes the property from generated `INSERT` metadata, keeps it readable, and keeps it updateable by default. Compose `.ExcludeFromUpdate()` when the value should remain database-controlled after insert. + +### Computed + +Use `Computed()` for values calculated by the database: + +```csharp +Map(product => product.Total) + .ToColumn("total") + .Computed(); +``` + +Computed properties participate in reads and are excluded from generated `INSERT` and `UPDATE` metadata. Inherited explicit mappings can be included when the derived entity should reuse a base entity map: @@ -411,13 +460,39 @@ FluentMapper.Initialize(config => }); ``` -Dommel honors FluentMap persistence metadata for generated INSERT and UPDATE +Dommel honors FluentMap persistence metadata for generated `INSERT` and `UPDATE` commands. `ReadOnly()` and `Computed()` are selected but not written, -`DatabaseDefaultOnInsert()` and `ExcludeFromInsert()` are omitted from INSERT +`DatabaseDefaultOnInsert()` and `ExcludeFromInsert()` are omitted from `INSERT` while remaining updateable, and `ExcludeFromUpdate()` remains insertable but is -not written by UPDATE. Assigned keys can be configured with -`IsKey().SetGeneratedOption(DatabaseGeneratedOption.None)` so they participate in -INSERT instead of being treated as database-generated identities. +not written by `UPDATE`. These behaviors are metadata in the core package; Dommel +is the package that turns them into generated SQL behavior. + +Key metadata is Dommel-specific: + +```csharp +Map(product => product.Id) + .ToColumn("product_id") + .IsKey() + .IsIdentity(); + +Map(product => product.Code) + .ToColumn("product_code") + .IsKey() + .SetGeneratedOption(DatabaseGeneratedOption.None); +``` + +`IsKey()` identifies the row. `IsIdentity()` marks a database-generated identity +key, excluded from `INSERT` and from `UPDATE SET`. A non-identity key is assigned +by the application, participates in `INSERT`, and is used by Dommel in the +`UPDATE WHERE` clause rather than in `UPDATE SET`. + +### Compatibility Notes + +Historical FluentMap code sometimes used `Ignore()` to keep a property out of +Dommel `INSERT` or `UPDATE`. Keep `Ignore()` only for values that should not be +materialized. For database-generated values that must still be read, use the +persistence behavior that matches the intent: `ReadOnly()`, `Computed()`, +`DatabaseDefaultOnInsert()`, `ExcludeFromInsert()` or `ExcludeFromUpdate()`. ## Current Limitations @@ -556,7 +631,56 @@ Map(product => product.Total) .Computed(); ``` -`Ignore()` mantém seu significado histórico: a propriedade não participa da materialização do FluentMap nem da metadata de persistência gerada. `ReadOnly()` ainda permite materialização, mas exclui a propriedade da metadata de INSERT e UPDATE. +### Ignore + +`Ignore()` mantém seu significado histórico: a propriedade não participa da materialização do FluentMap nem da metadata de persistência gerada. + +```csharp +Map(product => product.TransientValue) + .Ignore(); +``` + +Não use `Ignore()` para valores do banco que ainda devem ser selecionados. Ele não é o mesmo que metadata de persistência read-only. + +### Read-only + +Use `ReadOnly()` para valores do banco que são selecionados, mas não escritos por operações de persistência geradas: + +```csharp +Map(product => product.UpdatedAt) + .ToColumn("updated_at") + .ReadOnly(); +``` + +```text +SELECT: participa +INSERT: excluido +UPDATE: excluido +``` + +### Defaults de Banco + +Use `DatabaseDefaultOnInsert()` quando o banco fornece o valor inicial se a coluna for omitida do `INSERT`, por exemplo uma coluna `created_at DEFAULT ...`: + +```csharp +Map(product => product.CreatedAt) + .ToColumn("created_at") + .DatabaseDefaultOnInsert(); +``` + +Isso exclui a propriedade da metadata de `INSERT` gerado, mantém a leitura e preserva `UPDATE` por default. Componha `.ExcludeFromUpdate()` quando o valor também deve permanecer controlado pelo banco depois do insert. + +### Computed + +Use `Computed()` para valores calculados pelo banco: + +```csharp +Map(product => product.Total) + .ToColumn("total") + .Computed(); +``` + +Propriedades computed participam de leituras e são excluídas da metadata de `INSERT` e `UPDATE` gerados. Mapeamentos explícitos herdados podem ser incluídos quando a entidade derivada deve reutilizar um map da entidade base: @@ -859,13 +983,40 @@ FluentMapper.Initialize(config => }); ``` -A integração Dommel respeita a metadata de persistência em comandos INSERT e -UPDATE gerados. `ReadOnly()` e `Computed()` são selecionados, mas não escritos; -`DatabaseDefaultOnInsert()` e `ExcludeFromInsert()` são omitidos do INSERT e +A integração Dommel respeita a metadata de persistência em comandos `INSERT` e +`UPDATE` gerados. `ReadOnly()` e `Computed()` são selecionados, mas não escritos; +`DatabaseDefaultOnInsert()` e `ExcludeFromInsert()` são omitidos do `INSERT` e continuam atualizáveis; `ExcludeFromUpdate()` continua inserível, mas não é -escrito pelo UPDATE. Chaves atribuídas pela aplicação podem ser configuradas com -`IsKey().SetGeneratedOption(DatabaseGeneratedOption.None)` para participar do -INSERT em vez de serem tratadas como identities geradas pelo banco. +escrito pelo `UPDATE`. Esses comportamentos são metadata no pacote core; o +Dommel é o pacote que os transforma em comportamento de SQL gerado. + +Metadata de chave é específica do Dommel: + +```csharp +Map(product => product.Id) + .ToColumn("product_id") + .IsKey() + .IsIdentity(); + +Map(product => product.Code) + .ToColumn("product_code") + .IsKey() + .SetGeneratedOption(DatabaseGeneratedOption.None); +``` + +`IsKey()` identifica a linha. `IsIdentity()` marca uma identity gerada pelo banco, +excluída de `INSERT` e do `SET` de `UPDATE`. Uma key non-identity é atribuída +pela aplicação, participa do `INSERT` e é usada pelo Dommel no `WHERE` do +`UPDATE`, não no `SET`. + +### Notas de Compatibilidade + +Código FluentMap histórico às vezes usava `Ignore()` para remover uma +propriedade do `INSERT` ou `UPDATE` do Dommel. Mantenha `Ignore()` apenas para +valores que não devem ser materializados. Para valores gerados pelo banco que +ainda devem ser lidos, use o persistence behavior correspondente: +`ReadOnly()`, `Computed()`, `DatabaseDefaultOnInsert()`, `ExcludeFromInsert()` ou +`ExcludeFromUpdate()`. ## Limitações Atuais diff --git a/src/Dapper.FluentMap/Mapping/PropertyPersistenceMetadata.cs b/src/Dapper.FluentMap/Mapping/PropertyPersistenceMetadata.cs index d3872ac..0ed89bb 100644 --- a/src/Dapper.FluentMap/Mapping/PropertyPersistenceMetadata.cs +++ b/src/Dapper.FluentMap/Mapping/PropertyPersistenceMetadata.cs @@ -170,6 +170,11 @@ internal PropertyPersistenceMetadata Computed() EnsureNotIgnored(); EnsureNotKey(nameof(Computed)); + if (HasDatabaseDefaultOnInsert) + { + throw new FluentMapConfigurationException("A database-default property cannot also be configured as computed."); + } + return With( participatesInInsert: false, participatesInUpdate: false, diff --git a/test/Dapper.FluentMap.Analyzers.Tests/FluentMapConfigurationAnalyzerTests.cs b/test/Dapper.FluentMap.Analyzers.Tests/FluentMapConfigurationAnalyzerTests.cs index a3dba53..58b819e 100644 --- a/test/Dapper.FluentMap.Analyzers.Tests/FluentMapConfigurationAnalyzerTests.cs +++ b/test/Dapper.FluentMap.Analyzers.Tests/FluentMapConfigurationAnalyzerTests.cs @@ -279,6 +279,31 @@ public CustomerMap() AssertDiagnosticLineContains(source, diagnostic, "DatabaseDefaultOnInsert()"); } + [Fact] + public async Task DatabaseDefaultAndComputedShouldReportDfm012() + { + var source = @" +using Dapper.FluentMap.Mapping; + +public sealed class Customer +{ + public string Total { get; set; } +} + +public sealed class CustomerMap : EntityMap +{ + public CustomerMap() + { + Map(c => c.Total).DatabaseDefaultOnInsert().Computed(); + } +}"; + + var diagnostic = await GetSingleDiagnosticAsync(source, FluentMapConfigurationAnalyzer.InvalidPersistenceBehaviorDiagnosticId); + + AssertDiagnostic(diagnostic, DiagnosticSeverity.Error, "computed values cannot also be configured with DatabaseDefaultOnInsert()"); + AssertDiagnosticLineContains(source, diagnostic, "Computed()"); + } + [Fact] public async Task ComputedAndKeyShouldReportDfm012() { diff --git a/test/Dapper.FluentMap.Tests/PropertyPersistenceMetadataTests.cs b/test/Dapper.FluentMap.Tests/PropertyPersistenceMetadataTests.cs index bc4228c..af03684 100644 --- a/test/Dapper.FluentMap.Tests/PropertyPersistenceMetadataTests.cs +++ b/test/Dapper.FluentMap.Tests/PropertyPersistenceMetadataTests.cs @@ -150,6 +150,15 @@ public void ComputedAndDatabaseDefaultShouldThrow() Assert.Contains("database default", exception.Message, StringComparison.OrdinalIgnoreCase); } + [Fact] + public void DatabaseDefaultAndComputedShouldThrow() + { + var exception = Assert.Throws(() => new DefaultThenComputedPersistenceMap()); + + Assert.Contains("database-default", exception.Message, StringComparison.OrdinalIgnoreCase); + Assert.Contains("computed", exception.Message, StringComparison.OrdinalIgnoreCase); + } + [Fact] public void ExplainShouldExposePersistenceMetadata() { @@ -326,6 +335,14 @@ public ComputedThenDefaultPersistenceMap() } } + private sealed class DefaultThenComputedPersistenceMap : EntityMap + { + public DefaultThenComputedPersistenceMap() + { + Map(e => e.CreatedAt).DatabaseDefaultOnInsert().Computed(); + } + } + private class PersistenceBaseEntity { public DateTime CreatedAt { get; set; } From 23b74118dc17d8d42b03a352ec8bac48bd9d611b Mon Sep 17 00:00:00 2001 From: Rodrigo de Oliveira Date: Tue, 28 Jul 2026 14:35:30 -0300 Subject: [PATCH 15/49] docs(sdd): define advanced query materialization architecture --- .sdd/etapa-9/01-historical-query-issues.md | 181 ++++++++++ .../02-advanced-query-materialization-spec.md | 310 ++++++++++++++++++ .sdd/etapa-9/DECISIONS.md | 265 +++++++++++++++ .sdd/etapa-9/STATUS.md | 149 +++++++++ 4 files changed, 905 insertions(+) create mode 100644 .sdd/etapa-9/01-historical-query-issues.md create mode 100644 .sdd/etapa-9/02-advanced-query-materialization-spec.md create mode 100644 .sdd/etapa-9/DECISIONS.md create mode 100644 .sdd/etapa-9/STATUS.md diff --git a/.sdd/etapa-9/01-historical-query-issues.md b/.sdd/etapa-9/01-historical-query-issues.md new file mode 100644 index 0000000..c84921b --- /dev/null +++ b/.sdd/etapa-9/01-historical-query-issues.md @@ -0,0 +1,181 @@ +# Historical Query Issues + +Discovery executado para a Etapa 9 em 2026-07-28. + +Fontes historicas: + +- Issue #22: https://github.com/henkmollema/Dapper-FluentMap/issues/22 +- Issue #43: https://github.com/henkmollema/Dapper-FluentMap/issues/43 +- Issue #42 relacionada a multi-mapping: https://github.com/henkmollema/Dapper-FluentMap/issues/42 +- Issue #62, plano v2 que relaciona #42, #43 e #56: https://github.com/henkmollema/Dapper-FluentMap/issues/62 + +## Issue #22 + +### Problema original + +Issue "Conventions not working on 'Multiple Results'", aberta em 2015-01-30. +O reporter partiu do exemplo de `QueryMultiple` do README do Dapper: + +```csharp +using (var multi = connection.QueryMultiple(sql, new { id = selectedId })) +{ + var customer = multi.Read().Single(); + var orders = multi.Read().ToList(); + var returns = multi.Read().ToList(); +} +``` + +O comportamento relatado era que convencoes configuradas no FluentMap nao eram +aplicadas aos POCOs retornados pelos result sets. + +### Causa historica + +A causa nao foi isolada no historico publico. Nos comentarios, o mantenedor +testou um cenario com `QueryMultiple`, `multi.Read()` e +`multi.Read()` usando uma convencao de transformacao para colunas com +underscore, e relatou que funcionava. A issue foi fechada por falta de +informacao adicional. + +A interpretacao arquitetural para a Etapa 9 e que #22 nao prova uma falha +remanescente de Dapper `GridReader`, mas prova que multiplos result sets sempre +foram uma area sem regressao clara no FluentMap. + +### Estado atual do fork + +O fork atual registra type maps globais do Dapper para mapas e convencoes por +entidade. Portanto `connection.QueryMultiple(...).Read()` deve conseguir +usar root-level explicit mappings e conventions quando o caminho normal do +Dapper consulta o type map global da entidade. + +Entretanto, as APIs opt-in `QueryMapped*` do fork nao participam de +`QueryMultiple`: elas executam um unico reader via `SqlMapper.ExecuteReader`, +bufferizam todas as linhas em `List` e fecham o reader antes de +retornar. Nao ha `QueryMultipleMapped` nem `ReadMapped()`. + +Nao foi localizado teste de regressao dedicado para #22 no fork atual. + +### Ainda reproduzivel? + +Nao foi reproduzido nesta discovery porque este prompt nao deve implementar +features produtivas nem criar regressao executavel ainda. + +Estado de risco: + +- para root-level Dapper mapping, provavelmente nao reproduzivel se o type map + global estiver instalado corretamente; +- para materializacao avancada do fork, ainda nao suportado em + `QueryMultiple`, porque nao existe API `ReadMapped()`; +- para profiles, geracao runtime/generated e nested/value-object + materialization em multiplos result sets, ainda nao ha cobertura. + +### Solucao arquitetural proposta + +Implementar infraestrutura propria de multiple result sets para o caminho +opt-in do FluentMap, sem alterar internals do Dapper: + +- `QueryMultipleMapped(...)` deve executar o comando por APIs publicas do + Dapper ou ADO.NET e retornar um wrapper disposable controlado pelo FluentMap. +- O wrapper deve expor `ReadMapped()` e + `ReadMapped()`. +- O wrapper deve materializar cada result set pelo mesmo dispatch + generated-then-runtime usado por `QueryMapped*`. +- Para root-level Dapper behavior sem materializacao avancada, manter + `connection.QueryMultiple(...).Read()` como caminho Dapper normal. + +Nao alterar `SqlMapper.GridReader` por reflection, heranca ou acesso a membros +nao publicos. A API publica do `GridReader` em Dapper 2.1.79 nao fornece um +reader publico suficiente para reutilizar diretamente o materializador atual. + +### Regression coverage necessaria + +- `QueryMultipleMapped` com tres result sets e chamadas sequenciais + `ReadMapped()`, `ReadMapped()`, `ReadMapped()`. +- Convencoes aplicadas por entidade em result sets diferentes. +- Mapeamento explicito por entidade em result sets diferentes. +- Result set dinamico/escalares continuam sendo responsabilidade de Dapper ou + de uma API explicitamente nao mapeada, se criada. +- Ordem de consumo obrigatoria: tentar ler fora de ordem ou ler apos dispose + deve falhar de forma previsivel. +- Fechamento do reader/command quando o wrapper e descartado. + +## Issue #43 + +### Problema original + +Issue "Does not appear to work with QueryMultiple and .Read<>", aberta em +2016-08-10. O reporter usava uma stored procedure com tres tabelas: + +```csharp +var ds = con.QueryMultiple("uspSELNodes", p, commandType: CommandType.StoredProcedure); +var rows = ds.Read().ToDictionary(v => v.RowNo); +var total = ds.Read().First(); +var columns = ds.Read(); +``` + +As colunas que batiam pelo nome eram preenchidas pelo Dapper, mas as colunas +mapeadas por `EntityMap` ficavam com valores default. + +### Causa historica + +Os comentarios ligam #43 a uma regressao entre Dapper.FluentMap 1.4.1 e 1.5.x, +possivelmente relacionada a #42. O reporter confirmou que 1.4.1 funcionava. +Em 2018-11-16, o mantenedor marcou #43 como corrigida na versao 1.7.0 para full +.NET e .NET Core 2.0/2.1. + +A issue #42, embora seja sobre Dapper multi-mapping em um unico result set, +registrou uma excecao de convencoes ambiguas em 1.5.x. A issue #62 citou #42 e +#43 como relacionadas a melhorias de v2, incluindo remocao de dependencia de +`ReflectedType`/`DeclaringType`. + +### Estado atual do fork + +O fork atual ainda possui caminhos que comparam propriedades por +`PropertyInfo.ReflectedType` em convencoes no target nao `NETSTANDARD1_3`, mas +tambem possui melhorias posteriores de `MemberPath`, inherited mapping, +validation, profiles e generated materialization. + +O problema especifico de #43 no caminho Dapper normal pode estar resolvido pelo +type map global atual, mas nao existe cobertura dedicada localizada para +`QueryMultiple().Read()`. + +O problema para materializacao avancada continua fora do escopo implementado: +`QueryMapped*` nao oferece `QueryMultiple` nem `ReadMapped`. + +### Ainda reproduzivel? + +Nao determinado por execucao nesta discovery. A reproducao exata exigiria um +teste de integracao com SQLite ou provider equivalente usando multiplos +statements/result sets, quando o provider suportar `NextResult`. + +Para a Etapa 9, tratar como lacuna de cobertura e design, nao como permissao +para mexer em internals do Dapper. + +### Solucao arquitetural proposta + +Separar dois cenarios: + +- `connection.QueryMultiple(...).Read()`: comportamento Dapper normal, + protegido por regressao historica para root-level explicit mappings e + conventions. +- `connection.QueryMultipleMapped(...).ReadMapped()`: novo caminho opt-in + para materializacao avancada, profiles e equivalencia runtime/generated. + +O wrapper FluentMap deve ter ownership claro do reader/command que criou. Se a +API escolher aceitar um `GridReader` existente, ela deve ser limitada a +operacoes que a API publica do Dapper permite; como `GridReader.Reader` nao e +publico, essa alternativa nao deve ser o caminho principal. + +### Regression coverage necessaria + +- Regressao historica #43 com terceiro result set mapeando colunas + `column_prefix`, `column_name`, `display_order`, `can_be_ordered`, + `can_be_filtered` e `column_width_in_pixels`. +- Variacao por convention para simular #22. +- Variacao com `ReadMapped()` e, se decidido, teste separado para + `QueryMultiple().Read()` no caminho Dapper normal. +- Equivalencia generated/runtime para o mesmo result set, incluindo shape + ordenado que aciona generated e shape alternativo que cai para runtime. +- Falha de configuracao em profile ausente preservando + `FluentMapConfigurationException`. +- Materializacao de scalar result set deve permanecer via Dapper ou API + explicitamente fora do FluentMap advanced materialization. diff --git a/.sdd/etapa-9/02-advanced-query-materialization-spec.md b/.sdd/etapa-9/02-advanced-query-materialization-spec.md new file mode 100644 index 0000000..a5c52fc --- /dev/null +++ b/.sdd/etapa-9/02-advanced-query-materialization-spec.md @@ -0,0 +1,310 @@ +# Advanced Query Materialization Specification + +Discovery executado para a Etapa 9 em 2026-07-28. + +## Objetivos + +- Suportar multiplos result sets no caminho opt-in de materializacao avancada do + FluentMap. +- Permitir leitura por entidade e por mapping profile em result sets distintos. +- Preservar a ordem de precedencia atual: mapping explicito, convencao, + comportamento default do Dapper. +- Preservar equivalencia entre generated materializer e runtime fallback por + entidade, profile e shape ordenado de colunas. +- Introduzir caminhos unbuffered/streaming com ownership e lifetime explicitos. +- Suportar cancellation em APIs assincronas sem esconder o contrato real do + provider. +- Continuar provider-independent e baseado em APIs publicas de Dapper/ADO.NET. + +## Nao objetivos + +A Etapa 9 nao deve implementar: + +- SQL parsing; +- geracao de SQL; +- query builder; +- LINQ provider; +- CRUD; +- graph aggregation automatica; +- identity map; +- change tracking; +- eager loading; +- `Include`; +- repository abstractions; +- materializacao automatica por `splitOn`; +- abstracao ampla de conexao ou transacao. + +Multiple Result Sets e Dapper Multi-Mapping sao conceitos diferentes. + +- Multiple Result Sets: um comando retorna varios grids sequenciais e o + consumidor chama `Read*` para cada grid. +- Dapper Multi-Mapping: uma unica linha de um unico result set e dividida em + varios objetos por `splitOn`. + +Multi-mapping por `splitOn` nao entra automaticamente nesta etapa. + +## QueryMultiple + +Dapper 2.1.79 oferece publicamente: + +- `SqlMapper.QueryMultiple(IDbConnection, string, object, IDbTransaction, int?, CommandType?)`; +- `SqlMapper.QueryMultiple(IDbConnection, CommandDefinition)`; +- `SqlMapper.QueryMultipleAsync(...)`; +- `SqlMapper.GridReader.Read(bool buffered = true)`; +- `SqlMapper.GridReader.ReadAsync(bool buffered = true)`; +- `SqlMapper.GridReader.ReadUnbufferedAsync()`; +- `SqlMapper.GridReader.Dispose()` e `DisposeAsync()`; +- propriedades publicas `IsConsumed` e `Command`. + +`GridReader.Reader`, `ResultIndex`, `CancellationToken`, `OnBeforeGrid` e +`OnAfterGrid` existem no tipo, mas nao sao superficie publica consumivel pelo +FluentMap. O design nao deve depender desses membros por reflection. + +A API preferencial para FluentMap e criar um wrapper proprio desde a execucao do +comando: + +```csharp +using var multi = connection.QueryMultipleMapped(sql); + +var customers = multi.ReadMapped(); +var orders = multi.ReadMapped(); +``` + +O wrapper deve controlar `IDataReader.NextResult()` / `DbDataReader.NextResultAsync` +e usar o mesmo materializador de linhas de `QueryMapped*`. + +## Multiple result sets + +O wrapper deve consumir result sets sequencialmente. Cada chamada `ReadMapped*` +opera no result set atual e avanca para o proximo apenas depois que o grid atual +for consumido ou descartado. + +Regras: + +- leitura fora de ordem nao e suportada; +- leitura concorrente de dois grids no mesmo wrapper nao e suportada; +- tentar ler apos dispose deve falhar com `ObjectDisposedException`; +- tentar iniciar um novo grid enquanto um grid unbuffered esta em andamento + deve falhar com excecao de uso invalido; +- grids escalares e dinamicos permanecem no caminho Dapper normal ou em APIs + separadas, se explicitamente aprovadas. + +## Profiles + +Profiles continuam query-scoped. A selecao deve estar na chamada de leitura: + +```csharp +var customers = multi.ReadMapped(); +``` + +Essa chamada nao deve alterar o type map global do Dapper. Cada result set pode +usar um profile diferente. Profile ausente deve continuar falhando com +`FluentMapConfigurationException`, como `QueryMapped()`. + +## Buffered materialization + +O caminho buffered deve ser a primeira entrega funcional porque preserva o +contrato simples atual: o reader fica aberto apenas durante a leitura interna e +o metodo retorna uma colecao ja materializada. + +Opcoes conceituais: + +```csharp +IEnumerable ReadMapped(); +IEnumerable ReadMapped(); +``` + +Apesar do retorno `IEnumerable`, o comportamento inicial deve ser buffered +para alinhar com `QueryMapped*` atual e reduzir risco de lifetime. + +## Unbuffered materialization + +O caminho unbuffered sincrono deve ser separado e explicito: + +```csharp +IEnumerable QueryMappedUnbuffered(...); +IEnumerable ReadMappedUnbuffered(); +``` + +O enumerador deve manter reader/command/conexao em uso ate a enumeracao +terminar ou o enumerador ser descartado. Isso precisa ser documentado e coberto +por testes de dispose antecipado. + +Para evitar armadilhas, uma API unbuffered nao deve ser confundida com o +`QueryMapped` buffered atual. + +## Async streaming + +O caminho assincrono deve expor `IAsyncEnumerable` apenas onde o target e as +dependencias permitirem assinatura publica estavel. + +Dapper 2.1.79 tem `QueryUnbufferedAsync` em `DbConnection` e +`GridReader.ReadUnbufferedAsync()`, mas o FluentMap precisa materializar via +`IDataRecord`/`DbDataReader` proprio para aplicar nested/value-object/profile. + +API conceitual: + +```csharp +IAsyncEnumerable QueryMappedUnbufferedAsync( + this DbConnection connection, + CommandDefinition command); +``` + +Para `netstandard2.0`, introduzir `IAsyncEnumerable` em API publica implica +dependencia e compatibilidade binaria com `Microsoft.Bcl.AsyncInterfaces`. Essa +decisao deve ser feita explicitamente antes da implementacao. + +## Cancellation + +`CommandDefinition` em Dapper 2.1.79 possui `CancellationToken`. APIs +assincronas novas devem aceitar `CommandDefinition` e overloads convenientes com +`CancellationToken`. + +Regras: + +- cancellation deve ser observada na abertura/execucao do comando quando o + provider suportar; +- no loop de streaming assincrono, verificar cancellation entre linhas; +- cancellation deve propagar `OperationCanceledException` ou excecao do provider + sem wrapping como erro de mapping; +- excecoes de mapping continuam seguindo a semantica atual do materializador. + +## Connection lifetime + +FluentMap nao deve assumir ownership de conexoes recebidas do usuario. O wrapper +deve preservar a regra do Dapper: se a conexao estava fechada e o FluentMap a +abriu para executar o comando, ela deve ser fechada ao encerrar o reader; se ja +estava aberta, permanece aberta. + +Essa regra precisa de teste com conexao inicialmente aberta e inicialmente +fechada, se o provider de teste permitir. + +## Reader lifetime + +Buffered: + +- o reader e lido ate o fim do grid dentro do metodo; +- o reader permanece vivo entre grids no wrapper; +- dispose do wrapper fecha o reader. + +Unbuffered: + +- o reader permanece vivo durante a enumeracao; +- dispose do enumerador deve consumir/descartar o grid atual conforme necessario + para liberar recursos; +- o wrapper nao pode avancar para o proximo grid enquanto o enumerador atual + estiver ativo. + +## Command lifetime + +Quando o FluentMap cria command/reader, o wrapper e dono do command. Dispose do +wrapper deve descartar command e reader, inclusive apos excecoes. + +Quando uma API apenas compoe `SqlMapper.QueryMultiple`, o `GridReader` do +Dapper e dono do command/reader. Como essa alternativa nao permite acesso +publico adequado ao reader, ela nao e o caminho principal para `ReadMapped`. + +## Exception semantics + +- Argumentos nulos devem falhar com `ArgumentNullException`, seguindo o padrao + atual. +- Profile ausente ou mapping invalido deve falhar com + `FluentMapConfigurationException`. +- Dominio/construtor que falha durante materializacao continua sendo wrapped em + `FluentMapConfigurationException` com inner exception, como hoje. +- Excecoes de ADO.NET/Dapper durante execucao, leitura, `NextResult` ou dispose + nao devem ser convertidas para excecoes de configuracao. +- `Single`/`First` semantics, se adicionadas para multiple result sets, devem + alinhar com os nomes do Dapper. + +## Generated materializer interaction + +Generated materializer deve continuar sendo lookup por: + +```text +EntityType + ProfileType opcional + ordered ColumnShape +``` + +Cada result set tem seu proprio column shape. O wrapper deve calcular as colunas +do grid atual antes de iterar linhas e usar: + +1. `TryGetGeneratedMaterializer`; +2. fallback runtime por `GetMaterializationPlan`. + +Generated materializers nao devem executar SQL, abrir conexao, avancar grids ou +possuir recursos. + +## Runtime fallback interaction + +O runtime fallback deve ser o mesmo `NestedMaterializationPlan` usado por +`QueryMapped*`. A cache key ja inclui tipo, profile e colunas ordenadas, entao +multiplos result sets naturalmente produzem planos separados quando os shapes +diferem. + +Se a Etapa 9 introduzir streaming, o plano deve ser criado uma vez por grid e +reutilizado por linha. + +## Null semantics + +Preservar as regras atuais: + +- subarvore nested fica `null` quando todas as colunas do subtree sao `DBNull`; +- subarvore e criada quando qualquer coluna do subtree tem valor; +- Value Object nullable recebe `null` quando aplicavel; +- valores `DBNull` em tipos valor nao nullable seguem default/conversao atual; +- `Ignore()` exclui a coluna da materializacao; +- metadata de escrita (`ReadOnly`, `Computed`, `DatabaseDefaultOnInsert`, + `ExcludeFromInsert`, `ExcludeFromUpdate`) e neutra para leitura. + +## Provider independence + +O design deve depender de `IDbConnection`, `DbConnection`, `IDataReader`, +`DbDataReader`, `IDataRecord`, `IDbCommand`, `CommandDefinition` e contratos +publicos do Dapper. Nao deve depender de SQLite, SQL Server, stored procedure +specifics ou parsing de SQL. + +Testes podem usar SQLite quando ele suportar o comportamento necessario. Se o +provider nao suportar multiplos result sets em um unico comando, usar um reader +fake/ADO.NET controlado para testes unitarios de materializacao e um provider +real para lifetime quando disponivel. + +## Performance expectations + +- Buffered deve ter overhead pequeno sobre `QueryMapped*` por grid. +- Unbuffered deve evitar alocacao de `List` e materializar por linha. +- Generated path deve manter a reducao de alocacao da Etapa 7. +- O primeiro grid pode pagar custo de shape/plan lookup; linhas seguintes nao + devem recomputar plano. +- Benchmarks devem separar steady state, cold start, generated, runtime + fallback, buffered e streaming. + +Nao documentar promessa publica de latencia sem benchmark estavel. + +## Backward compatibility + +- Nenhuma API existente deve ser removida. +- `QueryMapped` e `QueryMappedSingle` continuam buffered. +- As variantes assincronas existentes hoje cobrem apenas profiles + (`QueryMappedAsync` e + `QueryMappedSingleAsync`). A Etapa 9 pode adicionar + overloads default async como API aditiva, mas isso deve ser decidido + separadamente. +- `connection.QueryMultiple(...).Read()` continua sendo Dapper normal. +- Profiles continuam nao alterando type map global. +- Dommel nao entra no escopo salvo impacto comprovado no core. + +## Native AOT / trimming considerations + +`QueryMapped*` atual permanece anotado com `RequiresUnreferencedCode` e +`RequiresDynamicCode`, mesmo com generated materializers, porque pode cair para +runtime fallback. + +As novas APIs de materializacao avancada devem herdar essa postura ate existir +um contrato que garanta "generated-only" sem fallback. Opcoes futuras: + +- APIs normais anotadas como trimming/dynamic-code sensitive; +- API generated-only que falha se nao houver descriptor gerado para o shape; +- diagnostico por shape antes da execucao, sem prometer AOT full. + +Introduzir `IAsyncEnumerable` em API publica `netstandard2.0` tambem deve +ser avaliado como mudanca de dependencia/compatibilidade. diff --git a/.sdd/etapa-9/DECISIONS.md b/.sdd/etapa-9/DECISIONS.md new file mode 100644 index 0000000..01cd640 --- /dev/null +++ b/.sdd/etapa-9/DECISIONS.md @@ -0,0 +1,265 @@ +# Etapa 9 Architectural Decisions + +## ADR-1 - Wrapper de QueryMultiple + +### Contexto + +O FluentMap atual materializa consultas avancadas por `QueryMapped*`, que abre +um unico reader, bufferiza as linhas e fecha o reader antes de retornar. Dapper +2.1.79 oferece `QueryMultiple` e `GridReader`, mas nao expoe publicamente o +`DbDataReader` interno de forma adequada para materializacao customizada. + +### Decisao + +Projetar `QueryMultipleMapped(...)` como wrapper proprio do FluentMap, criado a +partir da execucao do comando, e nao como extensao que tenta extrair estado de +um `GridReader` existente. + +### Alternativas consideradas + +- Estender `SqlMapper.GridReader`: descartado porque exigiria internals ou + reflection. +- Usar `GridReader.Read()`: descartado para materializacao avancada porque + nao aplica profiles/nested/value-object/generated do FluentMap. +- Criar wrapper ADO.NET proprio: escolhido como direcao arquitetural. + +### Consequencias + +O FluentMap passa a ter responsabilidade clara sobre reader/command no caminho +mapped. A implementacao precisa reproduzir cuidadosamente lifetime similar ao +Dapper sem virar uma abstracao geral de SQL. + +## ADR-2 - Ownership do GridReader + +### Contexto + +`GridReader.Dispose()` fecha e descarta reader e command. A API publica expoe +`Command`, `IsConsumed` e metodos `Read*`, mas nao expoe o reader necessario +para materializacao customizada. + +### Decisao + +Nao assumir ownership nem inspecionar internals de `GridReader`. O ownership +principal da Etapa 9 sera de um wrapper FluentMap que controla recursos criados +por ele. + +### Alternativas consideradas + +- Aceitar `GridReader` em `ReadMapped`: inseguro porque nao ha reader publico. +- Duplicar logica interna do Dapper: descartado por manutencao e risco. +- Refletir membros protegidos/internos: descartado por compatibilidade. + +### Consequencias + +Usuarios que ja usam `QueryMultiple` continuam com Dapper normal. Usuarios que +precisam de materializacao avancada optam por `QueryMultipleMapped`. + +## ADR-3 - Generated vs runtime em multiplos result sets + +### Contexto + +A Etapa 7 definiu lookup generated por entidade, profile opcional e shape +ordenado de colunas, com fallback runtime. + +### Decisao + +Cada result set deve executar o mesmo dispatch: + +1. capturar column shape do grid atual; +2. tentar generated materializer; +3. cair para `NestedMaterializationPlan`. + +### Alternativas consideradas + +- Reusar um plano entre grids por entidade: descartado porque ordinais e nomes + podem mudar. +- Desabilitar generated em `QueryMultipleMapped`: descartado por quebrar a + equivalencia esperada da Etapa 7. + +### Consequencias + +Materializacao de cada grid fica independente e previsivel. A cache existente +continua adequada porque inclui tipo, profile e colunas ordenadas. + +## ADR-4 - Buffered vs streaming API + +### Contexto + +`QueryMapped*` atual e buffered. Streaming muda lifetime observavel e aumenta +risco de vazamento de reader/connection. + +### Decisao + +Manter APIs buffered como primeiro incremento e criar nomes explicitos para +unbuffered/streaming. + +### Alternativas consideradas + +- Tornar `QueryMapped` lazy: breaking behavioral change, descartado. +- Usar parametro `buffered` em toda API nova: familiar para Dapper, mas menos + seguro para discoverability de lifetime. +- Criar APIs `Unbuffered`: preferido para deixar lifetime visivel. + +### Consequencias + +Menor risco de compatibilidade. Streaming entra como contrato opt-in separado, +com testes especificos de dispose. + +## ADR-5 - Async streaming + +### Contexto + +Dapper 2.1.79 oferece `QueryUnbufferedAsync` e +`GridReader.ReadUnbufferedAsync()`, mas a materializacao avancada do +FluentMap precisa ler `IDataRecord`/`DbDataReader` e aplicar seu proprio plano. + +### Decisao + +Avaliar `IAsyncEnumerable` como API separada, preferencialmente em overloads +baseados em `DbConnection`/`DbDataReader`, com cancellation explicita. + +### Alternativas consideradas + +- Retornar `Task>` para streaming: descartado, isso implica + buffering. +- Usar diretamente `QueryUnbufferedAsync` do Dapper: nao aplica + materializacao avancada. + +### Consequencias + +A API pode exigir dependencia publica de async interfaces no target +`netstandard2.0`. Isso deve ser registrado como decisao de compatibilidade +antes da implementacao. + +## ADR-6 - Cancellation + +### Contexto + +`CommandDefinition` possui `CancellationToken` em Dapper 2.1.79. O FluentMap +atual aceita `CommandDefinition`, mas as APIs convenientes nao recebem token. + +### Decisao + +APIs assincronas novas devem aceitar `CommandDefinition` e overloads com +`CancellationToken`. O token deve ser propagado para execucao e observado +durante loops de streaming. + +### Alternativas consideradas + +- Depender apenas de `CommandDefinition`: correto, mas pouco discoverable. +- Adicionar token em todos os overloads existentes: aditivo, mas deve ser feito + com cuidado para evitar ambiguidade de overloads. + +### Consequencias + +Cancellation vira parte do contrato da Etapa 9. Excecao de cancelamento nao +deve ser wrapada como erro de mapping. + +## ADR-7 - Connection lifetime + +### Contexto + +Dapper normalmente nao assume ownership de conexoes do usuario. Quando abre uma +conexao fechada para um comando, fecha ao concluir. + +### Decisao + +O FluentMap deve preservar essa regra. O wrapper e dono de command/reader, mas +nao da conexao recebida. + +### Alternativas consideradas + +- Exigir conexao aberta: simples, mas menos alinhado a Dapper. +- Sempre fechar conexao no dispose: breaking/hostil para usuarios. + +### Consequencias + +A implementacao precisa registrar se abriu a conexao. Testes devem validar +conexao inicialmente aberta e fechada. + +## ADR-8 - Fallback + +### Contexto + +Generated materializers sao otimizacao, nao requisito funcional. O fork +preserva fallback runtime para maps dinamicos, conventions e shapes nao +gerados. + +### Decisao + +Todas as APIs normais da Etapa 9 devem manter fallback runtime. Uma API +generated-only so deve ser considerada em etapa futura. + +### Alternativas consideradas + +- Exigir generated para streaming: reduz reflection, mas quebra coverage de + maps atuais. +- Usar apenas runtime em multiple result sets: perde beneficios da Etapa 7. + +### Consequencias + +As novas APIs herdam warnings de trimming/dynamic-code do caminho atual. AOT +full continua nao declarado. + +## ADR-9 - API publica + +### Contexto + +Nomes conceituais avaliados: + +```csharp +using var multi = connection.QueryMultipleMapped(sql); +var customers = multi.ReadMapped(); +var legacy = multi.ReadMapped(); +connection.QueryMappedUnbuffered(...); +connection.QueryMappedUnbufferedAsync(...); +``` + +### Decisao + +Adotar como direcao de design: + +- `QueryMultipleMapped` para criar o wrapper; +- `ReadMapped` e `ReadMapped` no wrapper; +- nomes com `Unbuffered` para streaming sincrono/assincrono. + +A assinatura final deve ser escolhida no prompt de implementacao depois de +verificar overload ambiguity, target framework e XML docs. + +### Alternativas consideradas + +- `QueryMappedMultiple`: menos alinhado ao nome Dapper `QueryMultiple`. +- Parametro `profile` runtime em vez de generic `TProfile`: menos consistente + com API atual. +- Extensoes em `GridReader`: descartadas como caminho principal. + +### Consequencias + +A API fica discoverable para usuarios Dapper e preserva profiles por operacao. +Ainda sera necessario decidir se overloads async default sem profile entram no +mesmo incremento. + +## ADR-10 - FluentMap nao abstrai SQL/query execution alem do necessario + +### Contexto + +O objetivo do core e mapear colunas para membros e materializar objetos quando +o usuario opta por isso. O projeto nao deve virar ORM, query builder ou CRUD. + +### Decisao + +A Etapa 9 pode criar wrappers minimos para executar comandos e controlar +reader/lifetime apenas quando isso for necessario para aplicar materializacao +avancada. Ela nao deve modelar SQL, joins, includes, repositories ou tracking. + +### Alternativas consideradas + +- Criar API de query rica: fora de escopo. +- Reaproveitar Dommel para execucao: fora de escopo do core e mistura leitura + avancada com persistencia. + +### Consequencias + +O design permanece pequeno e compatibilidade com Dapper fica clara. Usuarios +continuam escrevendo SQL e escolhendo quando usar Dapper puro ou FluentMap +mapped. diff --git a/.sdd/etapa-9/STATUS.md b/.sdd/etapa-9/STATUS.md new file mode 100644 index 0000000..4ea22fb --- /dev/null +++ b/.sdd/etapa-9/STATUS.md @@ -0,0 +1,149 @@ +# Etapa 9 Status + +## Objetivo + +Definir a arquitetura de Advanced Query Materialization para multiplos result +sets, `QueryMultiple`, `ReadMapped`, profiles, buffering, streaming, +`IAsyncEnumerable`, cancellation, lifetime de recursos e equivalencia entre +materializacao generated e runtime, sem implementar features produtivas neste +prompt. + +## Concluido + +- Executado `git status` antes de alteracoes. +- Confirmada branch `feature/etapa-3`; nao estamos em `master`. +- Identificado item nao rastreado preexistente `src/Dapper.FluentMap/etapas/`, + deixado intacto. +- Lido `README.md`. +- Examinada `Dapper.FluentMap.sln`. +- Examinados projetos core, Dommel, analyzers, generators, testes, smoke AOT e + benchmarks. +- Lidos `.sdd/etapa-8/FINAL-REPORT.md` e `.sdd/etapa-8/STATUS.md`. +- Lido `.sdd/etapa-7/FINAL-REPORT.md` para contexto de generated + materialization. +- Confirmado que `.sdd/etapa-9/` nao existia e criada a pasta. +- Investigadas as APIs atuais `QueryMapped`, + `QueryMapped`, `QueryMappedSingle`, + `QueryMappedSingle`, `QueryMappedAsync` e + `QueryMappedSingleAsync`. +- Investigado runtime materializer (`NestedMaterializationPlan`), cache de + planos, generated descriptors e registry. +- Confirmada dependencia efetiva `Dapper` 2.1.79 no core. +- Inspecionada superficie publica do Dapper 2.1.79 para `QueryMultiple`, + `GridReader`, `ExecuteReader`, `QueryUnbufferedAsync`, + `CommandDefinition`, `CommandFlags.Buffered` e cancellation. +- Lidas issues historicas #22 e #43, comentarios e eventos via GitHub/API. +- Consultadas issues relacionadas #42 e #62. +- Criado `.sdd/etapa-9/01-historical-query-issues.md`. +- Criado `.sdd/etapa-9/02-advanced-query-materialization-spec.md`. +- Criado `.sdd/etapa-9/DECISIONS.md`. +- Criado `.sdd/etapa-9/STATUS.md`. +- Executado `dotnet restore ./Dapper.FluentMap.sln`: sucesso. +- Executado `dotnet build ./Dapper.FluentMap.sln --configuration Release --no-restore`: + sucesso, 0 warnings, 0 errors. +- Executado `dotnet test ./Dapper.FluentMap.sln --configuration Release --no-build`: + sucesso, 298 testes aprovados. + +## Em andamento + +Nenhuma feature produtiva em andamento. Este prompt fecha discovery e +arquitetura. + +## Proximos passos + +1. QueryMultiple infrastructure. +2. `ReadMapped`. +3. Profiles. +4. Unbuffered synchronous path. +5. Async streaming. +6. Lifetime/cancellation hardening. +7. Regression/performance. +8. Documentacao final. + +## Decisoes relevantes + +- Nao alterar internals de `SqlMapper.GridReader`. +- Criar wrapper proprio `QueryMultipleMapped` como direcao principal. +- `ReadMapped` deve usar o mesmo dispatch generated-then-runtime de + `QueryMapped*`. +- Buffered deve ser entregue antes de streaming. +- Streaming deve ter nomes explicitos com `Unbuffered`. +- `IAsyncEnumerable` deve ser avaliado como mudanca de API/dependencia para + `netstandard2.0`. +- Cancellation deve usar `CommandDefinition.CancellationToken` e overloads + discoverable quando aprovados. +- FluentMap nao deve abstrair SQL alem do necessario para aplicar + materializacao avancada. + +## Issues historicas + +- #22: conventions em multiple results; historico inconclusivo, mas sem + regressao dedicada atual. +- #43: `QueryMultiple().Read()` nao aplicava mappings em 1.5.x; reporter + confirmou que 1.4.1 funcionava; mantenedor marcou corrigida em 1.7.0. +- #42: multi-mapping por `splitOn` em unico result set, relacionado + historicamente mas fora do escopo automatico da Etapa 9. +- #62: plano v2 cita #42 e #43 como relacionadas a melhorias de type mapping. + +## APIs propostas + +APIs conceituais a avaliar nos prompts de implementacao: + +```csharp +using var multi = connection.QueryMultipleMapped(sql); + +var customers = multi.ReadMapped(); +var legacyCustomers = multi.ReadMapped(); +``` + +```csharp +foreach (var customer in connection.QueryMappedUnbuffered(sql)) +{ +} +``` + +```csharp +await foreach (var customer in connection.QueryMappedUnbufferedAsync( + command, + cancellationToken)) +{ +} +``` + +Nomes finais ainda dependem de revisao de overloads, target framework e +compatibilidade. + +## Riscos conhecidos + +- `GridReader` nao expoe reader publico suficiente para `ReadMapped`. +- Implementar wrapper proprio exige lifetime correto de connection, command e + reader. +- Streaming pode vazar recursos se enumeradores nao forem descartados. +- Cancellation depende do suporte real do provider. +- `IAsyncEnumerable` em API publica `netstandard2.0` pode alterar + dependencias/compatibilidade. +- SQLite pode nao cobrir todos os cenarios reais de multiple result sets. +- Generated-only para AOT ainda nao existe; fallback runtime preserva warnings + de trimming/dynamic code. +- Tests de estado global precisam resetar FluentMapper e type maps por tipo. + +## Arquivos importantes + +- `.sdd/etapa-9/01-historical-query-issues.md` +- `.sdd/etapa-9/02-advanced-query-materialization-spec.md` +- `.sdd/etapa-9/DECISIONS.md` +- `.sdd/etapa-9/STATUS.md` +- `src/Dapper.FluentMap/QueryMappedExtensions.cs` +- `src/Dapper.FluentMap/MappingRegistry.cs` +- `src/Dapper.FluentMap/Materialization/NestedMaterializationPlan.cs` +- `src/Dapper.FluentMap/Materialization/GeneratedMaterializerDescriptor.cs` +- `src/Dapper.FluentMap/Materialization/GeneratedMaterializerColumn.cs` +- `src/Dapper.FluentMap/Materialization/GeneratedRowMaterializer.cs` +- `src/Dapper.FluentMap.Generators/MappingRegistrationGenerator.cs` +- `test/Dapper.FluentMap.Tests/MappingProfileTests.cs` +- `test/Dapper.FluentMap.Tests/GeneratedMaterializerContractTests.cs` +- `benchmarks/Dapper.FluentMap.Benchmarks/Program.cs` + +## Ultimo prompt executado + +Ultimo prompt executado: 9.1 From 1d67263251e5619db9f63f5fb5b5013b47df2078 Mon Sep 17 00:00:00 2001 From: Rodrigo de Oliveira Date: Tue, 28 Jul 2026 14:54:54 -0300 Subject: [PATCH 16/49] feat(query): add mapped multiple-result infrastructure --- .sdd/etapa-9/03-query-multiple-design.md | 208 +++++++ .sdd/etapa-9/DECISIONS.md | 37 ++ .sdd/etapa-9/STATUS.md | 59 +- README.md | 38 +- src/Dapper.FluentMap/MappedGridReader.cs | 111 ++++ .../Materialization/MappedRowMaterializer.cs | 56 ++ .../QueryMappedApiAnnotations.cs | 17 + src/Dapper.FluentMap/QueryMappedExtensions.cs | 168 +++--- .../QueryMultipleMappedTests.cs | 515 ++++++++++++++++++ 9 files changed, 1100 insertions(+), 109 deletions(-) create mode 100644 .sdd/etapa-9/03-query-multiple-design.md create mode 100644 src/Dapper.FluentMap/MappedGridReader.cs create mode 100644 src/Dapper.FluentMap/Materialization/MappedRowMaterializer.cs create mode 100644 src/Dapper.FluentMap/QueryMappedApiAnnotations.cs create mode 100644 test/Dapper.FluentMap.Tests/QueryMultipleMappedTests.cs diff --git a/.sdd/etapa-9/03-query-multiple-design.md b/.sdd/etapa-9/03-query-multiple-design.md new file mode 100644 index 0000000..703ca5c --- /dev/null +++ b/.sdd/etapa-9/03-query-multiple-design.md @@ -0,0 +1,208 @@ +# QueryMultipleMapped Design + +Prompt executado em 2026-07-28. + +## Objetivo + +Criar a primeira infraestrutura produtiva para multiplos result sets no caminho +opt-in de materializacao avancada do FluentMap. + +API implementada: + +```csharp +using var multi = connection.QueryMultipleMapped(sql, param, transaction); + +var customers = multi.ReadMapped(); +var orders = multi.ReadMapped(); +var legacy = multi.ReadMapped(); +``` + +## Wrapper escolhido + +O wrapper publico escolhido foi `MappedGridReader`. + +`QueryMultipleMapped(...)` abre um `IDataReader` por +`SqlMapper.ExecuteReader(connection, CommandDefinition)` e retorna +`MappedGridReader`. O FluentMap nao herda, encapsula nem inspeciona +`SqlMapper.GridReader`. + +Essa escolha preserva o encaminhamento publico do Dapper para parametros, +transacao, timeout e command type, enquanto mantem o FluentMap responsavel +apenas pela materializacao dos grids. + +## Ownership + +`MappedGridReader` e dono do `IDataReader` que recebe. + +O FluentMap nao e dono da `IDbConnection` recebida pelo usuario. A conexao segue +a regra operacional do Dapper/ADO.NET: + +- se Dapper abriu uma conexao que estava fechada, o dispose do reader fecha a + conexao; +- se a conexao ja estava aberta, o dispose do wrapper nao fecha a conexao. + +`SqlMapper.ExecuteReader` continua responsavel por criar e configurar o comando +com os parametros do `CommandDefinition`. + +## Disposal + +`MappedGridReader.Dispose()` descarta o reader subjacente e marca o wrapper como +consumido. + +Regras testadas: + +- dispose antes de consumir impede leituras posteriores com + `ObjectDisposedException`; +- dispose depois de consumo parcial fecha o reader e impede leitura dos grids + restantes; +- dispose depois do consumo completo e idempotente; +- excecao durante materializacao descarta o reader antes de propagar a excecao. + +## Semantica sincronica + +Este incremento implementa apenas o caminho buffered sincronico: + +- `ReadMapped()`; +- `ReadMapped()`. + +Cada chamada materializa todo o result set atual em memoria e so entao chama +`IDataReader.NextResult()` para posicionar o wrapper no proximo grid. + +Mesmo retornando `IEnumerable`, o resultado ja esta bufferizado, igual +ao contrato atual de `QueryMapped*`. + +## Semantica assincrona + +Nenhuma API publica assincrona de `QueryMultipleMapped` foi adicionada neste +prompt. + +Motivos: + +- o core publica `netstandard2.0`; +- async disposal publico exigiria uma decisao explicita sobre + `IAsyncDisposable`/`Microsoft.Bcl.AsyncInterfaces`; +- async streaming com `IAsyncEnumerable` tambem altera a superficie publica + e a matriz de compatibilidade. + +Essa decisao preserva o escopo do Prompt 9.2 como infraestrutura buffered. Os +prompts posteriores de streaming/async devem decidir dependencias e assinaturas +publicas separadamente. + +## Estado interno + +`MappedGridReader` mantem: + +- `_reader`: reader ADO.NET subjacente; +- `_disposed`: indica que o wrapper foi descartado; +- `_isConsumed`: indica que `NextResult()` retornou `false` ou que o wrapper + foi descartado. + +A propriedade publica `IsConsumed` retorna `true` quando todos os grids foram +consumidos ou quando o wrapper foi descartado, seguindo o padrao de descoberta +do `GridReader` do Dapper sem tentar replicar seus internals. + +## Consumo sequencial dos result sets + +O consumo e estritamente sequencial. + +Fluxo de cada `ReadMapped*`: + +1. valida que o wrapper nao foi descartado; +2. valida que ainda ha result set disponivel; +3. captura o shape de colunas do grid atual; +4. tenta materializador gerado registrado; +5. cai para `NestedMaterializationPlan` runtime quando necessario; +6. le todas as linhas do grid atual; +7. chama `NextResult()` uma vez para avancar o reader. + +Nao ha suporte a leitura concorrente ou leitura fora de ordem neste incremento. +Como o caminho atual e buffered, nao existe enumerador ativo entre chamadas. + +## Erros apos disposal + +Chamadas a `ReadMapped*` depois de `Dispose()` falham com +`ObjectDisposedException`. + +## Erros apos o ultimo result set + +Chamadas a `ReadMapped*` depois de `NextResult()` retornar `false` falham com +`InvalidOperationException` informando que nao ha result sets restantes. + +## Excecoes durante materializacao + +Excecoes do materializador runtime/generated sao propagadas sem troca de tipo. +Quando o runtime materializer encapsula falha de dominio em +`FluentMapConfigurationException`, esse comportamento e preservado. + +Ao capturar qualquer excecao durante materializacao ou `NextResult()`, o wrapper +descarta o reader antes de relancar a excecao para evitar vazamento de recurso. + +## Connection lifetime + +`QueryMultipleMapped` aceita `IDbConnection` e nao assume ownership da conexao. + +Testes cobrem: + +- conexao inicialmente fechada: aberta por Dapper e fechada no dispose do + wrapper; +- conexao inicialmente aberta: permanece aberta apos dispose do wrapper. + +## Transaction propagation + +O parametro `transaction` do overload conveniente e encaminhado para +`CommandDefinition`. + +O teste de infraestrutura usa SQLite em memoria com transacao ativa para +confirmar que a consulta e executada dentro da transacao recebida. + +## Command type + +O parametro `commandType` do overload conveniente e encaminhado para +`CommandDefinition`. + +Nao foi adicionado teste provider-specific de stored procedure porque o core +permanece provider-independent e SQLite nao oferece esse contrato. + +## Timeout + +O parametro `commandTimeout` do overload conveniente e encaminhado para +`CommandDefinition`. + +Nao ha teste deterministico de timeout neste prompt, para evitar flakiness e +dependencia de timing. + +## Parameters + +O parametro `param` e encaminhado para `CommandDefinition` e validado por teste +com query parametrizada em SQLite. + +## Cancellation + +Nao ha cancellation nova neste incremento porque a API implementada e +sincronica. + +`CommandDefinition` ja permanece disponivel no overload principal; quando APIs +assincronas forem adicionadas, elas devem propagar `CancellationToken` pelo +`CommandDefinition` e observar cancellation nos loops de leitura aplicaveis. + +## Materialization dispatch + +O dispatch foi extraido para `MappedRowMaterializer`, compartilhado por +`QueryMapped*` e `MappedGridReader`. + +Ordem preservada por grid: + +1. `FluentMapper.Registry.TryGetGeneratedMaterializer(...)`; +2. `FluentMapper.Registry.GetMaterializationPlan(...)` como fallback runtime. + +Cada result set recalcula seu proprio shape ordenado de colunas antes de +materializar linhas. + +## Limitacoes deste incremento + +- Sem unbuffered/streaming. +- Sem `IAsyncEnumerable`. +- Sem `QueryMultipleMappedAsync`. +- Sem leitura de grids dinamicos ou escalares pelo wrapper FluentMap. +- Sem extensao sobre `SqlMapper.GridReader` existente. +- Sem suporte a Dapper multi-mapping por `splitOn`. diff --git a/.sdd/etapa-9/DECISIONS.md b/.sdd/etapa-9/DECISIONS.md index 01cd640..6957473 100644 --- a/.sdd/etapa-9/DECISIONS.md +++ b/.sdd/etapa-9/DECISIONS.md @@ -263,3 +263,40 @@ avancada. Ela nao deve modelar SQL, joins, includes, repositories ou tracking. O design permanece pequeno e compatibilidade com Dapper fica clara. Usuarios continuam escrevendo SQL e escolhendo quando usar Dapper puro ou FluentMap mapped. + +## ADR-11 - API implementada no Prompt 9.2 + +### Contexto + +O Prompt 9.2 precisava entregar infraestrutura produtiva de multiple result +sets sem antecipar streaming, async disposal ou dependencia publica nova para +`netstandard2.0`. + +### Decisao + +Implementar `QueryMultipleMapped(...)` retornando `MappedGridReader`, com +`ReadMapped()` e `ReadMapped()` buffered. + +Usar `SqlMapper.ExecuteReader(connection, CommandDefinition)` para execucao do +comando e encaminhamento de parametros, transacao, timeout e command type. + +Extrair o dispatch de materializacao para `MappedRowMaterializer`, compartilhado +por `QueryMapped*` e `MappedGridReader`. + +Nao adicionar `QueryMultipleMappedAsync`, `IAsyncDisposable` ou +`IAsyncEnumerable` neste incremento. + +### Alternativas consideradas + +- Implementar command execution manual por ADO.NET: descartado para evitar + duplicar binding de parametros e comportamento publico do Dapper. +- Adicionar async/streaming ja no Prompt 9.2: descartado porque muda lifetime e + pode exigir dependencia publica adicional no target `netstandard2.0`. +- Usar `SqlMapper.GridReader`: descartado pelas ADRs anteriores, pois o reader + necessario nao e superficie publica. + +### Consequencias + +A API publica nova e aditiva e alinhada ao nome `QueryMultiple` do Dapper. O +primeiro incremento e buffered e sequencial. Streaming e async continuam +decisoes separadas para prompts posteriores. diff --git a/.sdd/etapa-9/STATUS.md b/.sdd/etapa-9/STATUS.md index 4ea22fb..c254a5c 100644 --- a/.sdd/etapa-9/STATUS.md +++ b/.sdd/etapa-9/STATUS.md @@ -2,11 +2,10 @@ ## Objetivo -Definir a arquitetura de Advanced Query Materialization para multiplos result -sets, `QueryMultiple`, `ReadMapped`, profiles, buffering, streaming, -`IAsyncEnumerable`, cancellation, lifetime de recursos e equivalencia entre -materializacao generated e runtime, sem implementar features produtivas neste -prompt. +Definir e evoluir a arquitetura de Advanced Query Materialization para +multiplos result sets, `QueryMultiple`, `ReadMapped`, profiles, buffering, +streaming, `IAsyncEnumerable`, cancellation, lifetime de recursos e +equivalencia entre materializacao generated e runtime. ## Concluido @@ -43,22 +42,45 @@ prompt. sucesso, 0 warnings, 0 errors. - Executado `dotnet test ./Dapper.FluentMap.sln --configuration Release --no-build`: sucesso, 298 testes aprovados. +- Prompt 9.2: criada especificacao refinada + `.sdd/etapa-9/03-query-multiple-design.md`. +- Prompt 9.2: implementado `QueryMultipleMapped(...)` com retorno + `MappedGridReader`. +- Prompt 9.2: implementados `MappedGridReader.ReadMapped()` e + `MappedGridReader.ReadMapped()`. +- Prompt 9.2: extraido `MappedRowMaterializer` para compartilhar dispatch + generated-then-runtime entre `QueryMapped*` e `QueryMultipleMapped`. +- Prompt 9.2: cobertos testes de criacao, primeiro result set, segundo result + set, profiles, empty result, invalid state, dispose antes/depois de consumo, + excecao durante materializacao, generated materializer, parametros, + transacao e lifetime de conexao. ## Em andamento -Nenhuma feature produtiva em andamento. Este prompt fecha discovery e -arquitetura. +Nenhuma feature produtiva em andamento. + +## Validacao do Prompt 9.2 + +- `dotnet test test\Dapper.FluentMap.Tests\Dapper.FluentMap.Tests.csproj --configuration Release --filter FullyQualifiedName~QueryMultipleMappedTests`: + sucesso, 13 testes aprovados. +- `dotnet build Dapper.FluentMap.sln --configuration Release`: sucesso, + 0 warnings, 0 errors. +- `dotnet test Dapper.FluentMap.sln --configuration Release --no-build`: + sucesso, 311 testes aprovados. +- `dotnet restore Dapper.FluentMap.sln`: sucesso. +- `dotnet pack src\Dapper.FluentMap\Dapper.FluentMap.csproj --configuration Release --no-build --output artifacts\packages`: + sucesso; warning legado `NU5125` sobre `licenseUrl`. +- Pacote inspecionado: + `lib/netstandard2.0/Dapper.FluentMap.dll` e + `lib/netstandard2.0/Dapper.FluentMap.xml` presentes. ## Proximos passos -1. QueryMultiple infrastructure. -2. `ReadMapped`. -3. Profiles. -4. Unbuffered synchronous path. -5. Async streaming. -6. Lifetime/cancellation hardening. -7. Regression/performance. -8. Documentacao final. +1. Unbuffered synchronous path. +2. Async streaming. +3. Lifetime/cancellation hardening para caminhos async/streaming. +4. Regression/performance. +5. Documentacao final. ## Decisoes relevantes @@ -66,6 +88,7 @@ arquitetura. - Criar wrapper proprio `QueryMultipleMapped` como direcao principal. - `ReadMapped` deve usar o mesmo dispatch generated-then-runtime de `QueryMapped*`. +- Prompt 9.2 implementou o caminho buffered sincronico antes de streaming. - Buffered deve ser entregue antes de streaming. - Streaming deve ter nomes explicitos com `Unbuffered`. - `IAsyncEnumerable` deve ser avaliado como mudanca de API/dependencia para @@ -131,8 +154,11 @@ compatibilidade. - `.sdd/etapa-9/01-historical-query-issues.md` - `.sdd/etapa-9/02-advanced-query-materialization-spec.md` +- `.sdd/etapa-9/03-query-multiple-design.md` - `.sdd/etapa-9/DECISIONS.md` - `.sdd/etapa-9/STATUS.md` +- `src/Dapper.FluentMap/MappedGridReader.cs` +- `src/Dapper.FluentMap/Materialization/MappedRowMaterializer.cs` - `src/Dapper.FluentMap/QueryMappedExtensions.cs` - `src/Dapper.FluentMap/MappingRegistry.cs` - `src/Dapper.FluentMap/Materialization/NestedMaterializationPlan.cs` @@ -142,8 +168,9 @@ compatibilidade. - `src/Dapper.FluentMap.Generators/MappingRegistrationGenerator.cs` - `test/Dapper.FluentMap.Tests/MappingProfileTests.cs` - `test/Dapper.FluentMap.Tests/GeneratedMaterializerContractTests.cs` +- `test/Dapper.FluentMap.Tests/QueryMultipleMappedTests.cs` - `benchmarks/Dapper.FluentMap.Benchmarks/Program.cs` ## Ultimo prompt executado -Ultimo prompt executado: 9.1 +Ultimo prompt executado: 9.2 diff --git a/README.md b/README.md index 3a297b7..2d4f2cf 100644 --- a/README.md +++ b/README.md @@ -358,6 +358,15 @@ var legacy = connection.QueryMappedSingle( Profiles are selected per `QueryMapped()` operation. They do not replace the global Dapper type map for the entity. +Profiles can also be selected per result set when using mapped multiple results: + +```csharp +using var multi = connection.QueryMultipleMapped(sql); + +var currentCustomers = multi.ReadMapped(); +var legacyCustomers = multi.ReadMapped(); +``` + ## Diagnostics Use runtime validation to fail fast after configuration: @@ -422,9 +431,13 @@ Use FluentMap query helpers when you need FluentMap-controlled advanced material connection.QueryMapped(sql); connection.QueryMappedSingle(sql); connection.QueryMappedSingle(sql); + +using var multi = connection.QueryMultipleMapped(sql); +var customers = multi.ReadMapped(); +var orders = multi.ReadMapped(); ``` -`QueryMapped*` returns buffered results and is the path that supports nested object materialization, constructor-built value objects and profile-specific mapping. +`QueryMapped*` and `ReadMapped*` return buffered results and are the paths that support nested object materialization, constructor-built value objects and profile-specific mapping. ## Dommel @@ -499,8 +512,8 @@ persistence behavior that matches the intent: `ReadOnly()`, `Computed()`, - 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*` may use generated materializers for supported flat, nested and Value Object shapes, but it can still fall back to runtime metadata and dynamic code; it is not yet a guaranteed Native AOT-safe materialization path. -- Mapping profiles are selected only through `QueryMapped()` APIs. -- `QueryMapped*` is buffered; it does not expose unbuffered streaming. +- Mapping profiles are selected through `QueryMapped()` and `ReadMapped()` APIs. +- `QueryMapped*` and `ReadMapped*` are buffered; they do not expose unbuffered streaming. - Value object construction uses matching public constructors, not factory methods. ## Contributing @@ -881,6 +894,15 @@ var legacy = connection.QueryMappedSingle( Profiles são selecionados por operação com `QueryMapped()`. Eles não substituem o type map global do Dapper para a entidade. +Profiles também podem ser selecionados por result set em multiplos resultados mapeados: + +```csharp +using var multi = connection.QueryMultipleMapped(sql); + +var currentCustomers = multi.ReadMapped(); +var legacyCustomers = multi.ReadMapped(); +``` + ## Diagnósticos Use validação em runtime para falhar cedo depois da configuração: @@ -945,9 +967,13 @@ Use os helpers de consulta do FluentMap quando precisar de materialização avan connection.QueryMapped(sql); connection.QueryMappedSingle(sql); connection.QueryMappedSingle(sql); + +using var multi = connection.QueryMultipleMapped(sql); +var customers = multi.ReadMapped(); +var orders = multi.ReadMapped(); ``` -`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. +`QueryMapped*` e `ReadMapped*` retornam resultados bufferizados e são os caminhos que suportam materialização de objetos aninhados, Value Objects construídos por construtor e mapeamento específico por profile. ## Dommel @@ -1023,8 +1049,8 @@ ainda devem ser lidos, use o persistence behavior correspondente: - 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*` pode usar materializadores gerados para shapes flat, aninhados e Value Object suportados, mas ainda pode cair para metadados de runtime e código dinâmico; ele ainda não é um caminho de materialização garantidamente seguro para Native AOT. -- Mapping profiles são selecionados apenas pelas APIs `QueryMapped()`. -- `QueryMapped*` é bufferizado; ele não expõe streaming unbuffered. +- Mapping profiles são selecionados pelas APIs `QueryMapped()` e `ReadMapped()`. +- `QueryMapped*` e `ReadMapped*` são bufferizados; eles não expõem streaming unbuffered. - A construção de Value Objects usa construtores públicos compatíveis, não factory methods. ## Contribuição diff --git a/src/Dapper.FluentMap/MappedGridReader.cs b/src/Dapper.FluentMap/MappedGridReader.cs new file mode 100644 index 0000000..6668ce6 --- /dev/null +++ b/src/Dapper.FluentMap/MappedGridReader.cs @@ -0,0 +1,111 @@ +using System; +using System.Collections.Generic; +using System.Data; +using System.Diagnostics.CodeAnalysis; +using Dapper.FluentMap.Materialization; +using Dapper.FluentMap.Mapping; + +namespace Dapper.FluentMap +{ + /// + /// Reads multiple result sets using FluentMap-controlled materialization. + /// + public sealed class MappedGridReader : IDisposable + { + private readonly IDataReader _reader; + private bool _disposed; + private bool _isConsumed; + + internal MappedGridReader(IDataReader reader) + { + _reader = reader ?? throw new ArgumentNullException(nameof(reader)); + } + + /// + /// Gets a value indicating whether all result sets have been consumed or the reader has been disposed. + /// + public bool IsConsumed => _isConsumed || _disposed; + + /// + /// Materializes the current result set and advances to the next one. + /// + /// The entity type to materialize. + /// The buffered materialized rows from the current result set. + [RequiresUnreferencedCode(QueryMappedApiAnnotations.RequiresUnreferencedCodeMessage)] + [RequiresDynamicCode(QueryMappedApiAnnotations.RequiresDynamicCodeMessage)] + public IEnumerable ReadMapped< + [DynamicallyAccessedMembers(QueryMappedApiAnnotations.MaterializedEntityMemberTypes)] + TEntity>() + where TEntity : class + { + return ReadMapped(profileType: null); + } + + /// + /// Materializes the current result set using the specified FluentMap mapping profile and advances to the next one. + /// + /// The entity type to materialize. + /// The mapping profile marker type to use. + /// The buffered materialized rows from the current result set. + [RequiresUnreferencedCode(QueryMappedApiAnnotations.RequiresUnreferencedCodeMessage)] + [RequiresDynamicCode(QueryMappedApiAnnotations.RequiresDynamicCodeMessage)] + public IEnumerable ReadMapped< + [DynamicallyAccessedMembers(QueryMappedApiAnnotations.MaterializedEntityMemberTypes)] + TEntity, + TProfile>() + where TEntity : class + where TProfile : IMappingProfile + { + return ReadMapped(typeof(TProfile)); + } + + /// + /// Releases the underlying data reader. + /// + public void Dispose() + { + if (_disposed) + { + return; + } + + _disposed = true; + _isConsumed = true; + _reader.Dispose(); + } + + private IEnumerable ReadMapped< + [DynamicallyAccessedMembers(QueryMappedApiAnnotations.MaterializedEntityMemberTypes)] + TEntity>( + Type profileType) + where TEntity : class + { + ThrowIfDisposed(); + + if (_isConsumed) + { + throw new InvalidOperationException("There are no remaining result sets to read."); + } + + try + { + var results = MappedRowMaterializer.Materialize(_reader, profileType); + _isConsumed = !_reader.NextResult(); + return results; + } + catch + { + Dispose(); + throw; + } + } + + private void ThrowIfDisposed() + { + if (_disposed) + { + throw new ObjectDisposedException(nameof(MappedGridReader)); + } + } + } +} diff --git a/src/Dapper.FluentMap/Materialization/MappedRowMaterializer.cs b/src/Dapper.FluentMap/Materialization/MappedRowMaterializer.cs new file mode 100644 index 0000000..3d7ccf1 --- /dev/null +++ b/src/Dapper.FluentMap/Materialization/MappedRowMaterializer.cs @@ -0,0 +1,56 @@ +using System; +using System.Collections.Generic; +using System.Data; +using System.Diagnostics.CodeAnalysis; + +namespace Dapper.FluentMap.Materialization +{ + internal static class MappedRowMaterializer + { + internal static IEnumerable Materialize< + [DynamicallyAccessedMembers(QueryMappedApiAnnotations.MaterializedEntityMemberTypes)] + TEntity>( + IDataReader reader, + Type profileType) + where TEntity : class + { + var columnNames = GetColumnNames(reader); + var results = new List(); + + Func generatedMaterializer; + if (FluentMapper.Registry.TryGetGeneratedMaterializer( + typeof(TEntity), + profileType, + columnNames, + out generatedMaterializer)) + { + while (reader.Read()) + { + results.Add((TEntity)generatedMaterializer(reader)); + } + + return results; + } + + var plan = FluentMapper.Registry.GetMaterializationPlan(typeof(TEntity), profileType, columnNames); + + while (reader.Read()) + { + results.Add((TEntity)plan.Materialize(reader)); + } + + return results; + } + + 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/src/Dapper.FluentMap/QueryMappedApiAnnotations.cs b/src/Dapper.FluentMap/QueryMappedApiAnnotations.cs new file mode 100644 index 0000000..078a5eb --- /dev/null +++ b/src/Dapper.FluentMap/QueryMappedApiAnnotations.cs @@ -0,0 +1,17 @@ +using System.Diagnostics.CodeAnalysis; + +namespace Dapper.FluentMap +{ + internal static class QueryMappedApiAnnotations + { + internal const DynamicallyAccessedMemberTypes MaterializedEntityMemberTypes = + DynamicallyAccessedMemberTypes.PublicConstructors | + DynamicallyAccessedMemberTypes.PublicProperties; + + internal const string RequiresUnreferencedCodeMessage = + "QueryMapped uses runtime mapping metadata to materialize nested objects. Prefer generated materializers when publishing trimmed or Native AOT applications."; + + internal const string RequiresDynamicCodeMessage = + "QueryMapped compiles runtime accessors for nested object materialization. Prefer generated materializers when publishing Native AOT applications."; + } +} diff --git a/src/Dapper.FluentMap/QueryMappedExtensions.cs b/src/Dapper.FluentMap/QueryMappedExtensions.cs index 4e0420f..8c99d29 100644 --- a/src/Dapper.FluentMap/QueryMappedExtensions.cs +++ b/src/Dapper.FluentMap/QueryMappedExtensions.cs @@ -14,16 +14,6 @@ namespace Dapper.FluentMap /// public static class QueryMappedExtensions { - private const DynamicallyAccessedMemberTypes MaterializedEntityMemberTypes = - DynamicallyAccessedMemberTypes.PublicConstructors | - 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. /// @@ -35,10 +25,10 @@ public static class QueryMappedExtensions /// Optional command timeout. /// Optional command type. /// The materialized rows. - [RequiresUnreferencedCode(QueryMappedRequiresUnreferencedCodeMessage)] - [RequiresDynamicCode(QueryMappedRequiresDynamicCodeMessage)] + [RequiresUnreferencedCode(QueryMappedApiAnnotations.RequiresUnreferencedCodeMessage)] + [RequiresDynamicCode(QueryMappedApiAnnotations.RequiresDynamicCodeMessage)] public static IEnumerable QueryMapped< - [DynamicallyAccessedMembers(MaterializedEntityMemberTypes)] + [DynamicallyAccessedMembers(QueryMappedApiAnnotations.MaterializedEntityMemberTypes)] TEntity>( this IDbConnection connection, string sql, @@ -75,10 +65,10 @@ public static IEnumerable QueryMapped< /// Optional command timeout. /// Optional command type. /// The materialized rows. - [RequiresUnreferencedCode(QueryMappedRequiresUnreferencedCodeMessage)] - [RequiresDynamicCode(QueryMappedRequiresDynamicCodeMessage)] + [RequiresUnreferencedCode(QueryMappedApiAnnotations.RequiresUnreferencedCodeMessage)] + [RequiresDynamicCode(QueryMappedApiAnnotations.RequiresDynamicCodeMessage)] public static IEnumerable QueryMapped< - [DynamicallyAccessedMembers(MaterializedEntityMemberTypes)] + [DynamicallyAccessedMembers(QueryMappedApiAnnotations.MaterializedEntityMemberTypes)] TEntity, TProfile>( this IDbConnection connection, @@ -107,10 +97,10 @@ public static IEnumerable QueryMapped< /// The database connection. /// The command to execute. /// The materialized rows. - [RequiresUnreferencedCode(QueryMappedRequiresUnreferencedCodeMessage)] - [RequiresDynamicCode(QueryMappedRequiresDynamicCodeMessage)] + [RequiresUnreferencedCode(QueryMappedApiAnnotations.RequiresUnreferencedCodeMessage)] + [RequiresDynamicCode(QueryMappedApiAnnotations.RequiresDynamicCodeMessage)] public static IEnumerable QueryMapped< - [DynamicallyAccessedMembers(MaterializedEntityMemberTypes)] + [DynamicallyAccessedMembers(QueryMappedApiAnnotations.MaterializedEntityMemberTypes)] TEntity>( this IDbConnection connection, CommandDefinition command) @@ -127,10 +117,10 @@ public static IEnumerable QueryMapped< /// The database connection. /// The command to execute. /// The materialized rows. - [RequiresUnreferencedCode(QueryMappedRequiresUnreferencedCodeMessage)] - [RequiresDynamicCode(QueryMappedRequiresDynamicCodeMessage)] + [RequiresUnreferencedCode(QueryMappedApiAnnotations.RequiresUnreferencedCodeMessage)] + [RequiresDynamicCode(QueryMappedApiAnnotations.RequiresDynamicCodeMessage)] public static IEnumerable QueryMapped< - [DynamicallyAccessedMembers(MaterializedEntityMemberTypes)] + [DynamicallyAccessedMembers(QueryMappedApiAnnotations.MaterializedEntityMemberTypes)] TEntity, TProfile>( this IDbConnection connection, @@ -152,10 +142,10 @@ public static IEnumerable QueryMapped< /// Optional command timeout. /// Optional command type. /// The materialized row. - [RequiresUnreferencedCode(QueryMappedRequiresUnreferencedCodeMessage)] - [RequiresDynamicCode(QueryMappedRequiresDynamicCodeMessage)] + [RequiresUnreferencedCode(QueryMappedApiAnnotations.RequiresUnreferencedCodeMessage)] + [RequiresDynamicCode(QueryMappedApiAnnotations.RequiresDynamicCodeMessage)] public static TEntity QueryMappedSingle< - [DynamicallyAccessedMembers(MaterializedEntityMemberTypes)] + [DynamicallyAccessedMembers(QueryMappedApiAnnotations.MaterializedEntityMemberTypes)] TEntity>( this IDbConnection connection, string sql, @@ -180,10 +170,10 @@ public static TEntity QueryMappedSingle< /// Optional command timeout. /// Optional command type. /// The materialized row. - [RequiresUnreferencedCode(QueryMappedRequiresUnreferencedCodeMessage)] - [RequiresDynamicCode(QueryMappedRequiresDynamicCodeMessage)] + [RequiresUnreferencedCode(QueryMappedApiAnnotations.RequiresUnreferencedCodeMessage)] + [RequiresDynamicCode(QueryMappedApiAnnotations.RequiresDynamicCodeMessage)] public static TEntity QueryMappedSingle< - [DynamicallyAccessedMembers(MaterializedEntityMemberTypes)] + [DynamicallyAccessedMembers(QueryMappedApiAnnotations.MaterializedEntityMemberTypes)] TEntity, TProfile>( this IDbConnection connection, @@ -210,10 +200,10 @@ public static TEntity QueryMappedSingle< /// Optional command timeout. /// Optional command type. /// The materialized rows. - [RequiresUnreferencedCode(QueryMappedRequiresUnreferencedCodeMessage)] - [RequiresDynamicCode(QueryMappedRequiresDynamicCodeMessage)] + [RequiresUnreferencedCode(QueryMappedApiAnnotations.RequiresUnreferencedCodeMessage)] + [RequiresDynamicCode(QueryMappedApiAnnotations.RequiresDynamicCodeMessage)] public static Task> QueryMappedAsync< - [DynamicallyAccessedMembers(MaterializedEntityMemberTypes)] + [DynamicallyAccessedMembers(QueryMappedApiAnnotations.MaterializedEntityMemberTypes)] TEntity, TProfile>( this IDbConnection connection, @@ -243,10 +233,10 @@ public static Task> QueryMappedAsync< /// The database connection. /// The command to execute. /// The materialized rows. - [RequiresUnreferencedCode(QueryMappedRequiresUnreferencedCodeMessage)] - [RequiresDynamicCode(QueryMappedRequiresDynamicCodeMessage)] + [RequiresUnreferencedCode(QueryMappedApiAnnotations.RequiresUnreferencedCodeMessage)] + [RequiresDynamicCode(QueryMappedApiAnnotations.RequiresDynamicCodeMessage)] public static Task> QueryMappedAsync< - [DynamicallyAccessedMembers(MaterializedEntityMemberTypes)] + [DynamicallyAccessedMembers(QueryMappedApiAnnotations.MaterializedEntityMemberTypes)] TEntity, TProfile>( this IDbConnection connection, @@ -269,10 +259,10 @@ public static Task> QueryMappedAsync< /// Optional command timeout. /// Optional command type. /// The materialized row. - [RequiresUnreferencedCode(QueryMappedRequiresUnreferencedCodeMessage)] - [RequiresDynamicCode(QueryMappedRequiresDynamicCodeMessage)] + [RequiresUnreferencedCode(QueryMappedApiAnnotations.RequiresUnreferencedCodeMessage)] + [RequiresDynamicCode(QueryMappedApiAnnotations.RequiresDynamicCodeMessage)] public static async Task QueryMappedSingleAsync< - [DynamicallyAccessedMembers(MaterializedEntityMemberTypes)] + [DynamicallyAccessedMembers(QueryMappedApiAnnotations.MaterializedEntityMemberTypes)] TEntity, TProfile>( this IDbConnection connection, @@ -295,27 +285,58 @@ public static async Task QueryMappedSingleAsync< return rows.Single(); } - private static IEnumerable ExecuteMapped< - [DynamicallyAccessedMembers(MaterializedEntityMemberTypes)] - TEntity>( - IDbConnection connection, - CommandDefinition command, - Type profileType) - where TEntity : class + /// + /// Executes a query and returns a reader for sequential FluentMap-controlled materialization of multiple result sets. + /// + /// The database connection. + /// The SQL command to execute. + /// Optional query parameters. + /// Optional transaction. + /// Optional command timeout. + /// Optional command type. + /// A disposable mapped multiple result reader. + [RequiresUnreferencedCode(QueryMappedApiAnnotations.RequiresUnreferencedCodeMessage)] + [RequiresDynamicCode(QueryMappedApiAnnotations.RequiresDynamicCodeMessage)] + public static MappedGridReader QueryMultipleMapped( + this IDbConnection connection, + string sql, + object param = null, + IDbTransaction transaction = null, + int? commandTimeout = null, + CommandType? commandType = null) { - if (connection == null) + if (sql == null) { - throw new ArgumentNullException(nameof(connection)); + throw new ArgumentNullException(nameof(sql)); } - using (var reader = SqlMapper.ExecuteReader(connection, command)) + return QueryMultipleMapped( + connection, + new CommandDefinition(sql, param, transaction, commandTimeout, commandType)); + } + + /// + /// Executes a command and returns a reader for sequential FluentMap-controlled materialization of multiple result sets. + /// + /// The database connection. + /// The command to execute. + /// A disposable mapped multiple result reader. + [RequiresUnreferencedCode(QueryMappedApiAnnotations.RequiresUnreferencedCodeMessage)] + [RequiresDynamicCode(QueryMappedApiAnnotations.RequiresDynamicCodeMessage)] + public static MappedGridReader QueryMultipleMapped( + this IDbConnection connection, + CommandDefinition command) + { + if (connection == null) { - return Materialize(reader, profileType); + throw new ArgumentNullException(nameof(connection)); } + + return new MappedGridReader(SqlMapper.ExecuteReader(connection, command)); } - private static async Task> ExecuteMappedAsync< - [DynamicallyAccessedMembers(MaterializedEntityMemberTypes)] + private static IEnumerable ExecuteMapped< + [DynamicallyAccessedMembers(QueryMappedApiAnnotations.MaterializedEntityMemberTypes)] TEntity>( IDbConnection connection, CommandDefinition command, @@ -327,56 +348,29 @@ private static async Task> ExecuteMappedAsync< throw new ArgumentNullException(nameof(connection)); } - using (var reader = await SqlMapper.ExecuteReaderAsync(connection, command).ConfigureAwait(false)) + using (var reader = SqlMapper.ExecuteReader(connection, command)) { - return Materialize(reader, profileType); + return MappedRowMaterializer.Materialize(reader, profileType); } } - private static IEnumerable Materialize< - [DynamicallyAccessedMembers(MaterializedEntityMemberTypes)] + private static async Task> ExecuteMappedAsync< + [DynamicallyAccessedMembers(QueryMappedApiAnnotations.MaterializedEntityMemberTypes)] TEntity>( - IDataReader reader, + IDbConnection connection, + CommandDefinition command, Type profileType) where TEntity : class { - var columnNames = GetColumnNames(reader); - var results = new List(); - - Func generatedMaterializer; - if (FluentMapper.Registry.TryGetGeneratedMaterializer( - typeof(TEntity), - profileType, - columnNames, - out generatedMaterializer)) - { - while (reader.Read()) - { - results.Add((TEntity)generatedMaterializer(reader)); - } - - return results; - } - - var plan = FluentMapper.Registry.GetMaterializationPlan(typeof(TEntity), profileType, columnNames); - - while (reader.Read()) + if (connection == null) { - results.Add((TEntity)plan.Materialize(reader)); + throw new ArgumentNullException(nameof(connection)); } - return results; - } - - private static string[] GetColumnNames(IDataRecord reader) - { - var columnNames = new string[reader.FieldCount]; - for (var i = 0; i < columnNames.Length; i++) + using (var reader = await SqlMapper.ExecuteReaderAsync(connection, command).ConfigureAwait(false)) { - columnNames[i] = reader.GetName(i); + return MappedRowMaterializer.Materialize(reader, profileType); } - - return columnNames; } } } diff --git a/test/Dapper.FluentMap.Tests/QueryMultipleMappedTests.cs b/test/Dapper.FluentMap.Tests/QueryMultipleMappedTests.cs new file mode 100644 index 0000000..4a62d2a --- /dev/null +++ b/test/Dapper.FluentMap.Tests/QueryMultipleMappedTests.cs @@ -0,0 +1,515 @@ +using System; +using System.Data; +using System.Linq; +using Dapper; +using Dapper.FluentMap.Mapping; +using Dapper.FluentMap.Materialization; +using Microsoft.Data.Sqlite; +using Xunit; + +namespace Dapper.FluentMap.Tests +{ + public class QueryMultipleMappedTests + { + [Fact] + [Trait("Category", "Integration")] + public void QueryMultipleMappedShouldCreateReaderAndReadFirstResultSet() + { + PreTest(typeof(MappedCustomer)); + + try + { + FluentMapper.Initialize(configuration => configuration.AddMap(new MappedCustomerMap())); + + using (var connection = OpenConnection()) + using (var multi = connection.QueryMultipleMapped( + "SELECT 1 AS customer_id, 'Ada' AS customer_name;")) + { + var customers = multi.ReadMapped().ToList(); + + Assert.Collection( + customers, + customer => + { + Assert.Equal(1, customer.Id); + Assert.Equal("Ada", customer.Name); + }); + Assert.True(multi.IsConsumed); + } + } + finally + { + PreTest(typeof(MappedCustomer)); + } + } + + [Fact] + public void ReadMappedShouldReadSequentialResultSets() + { + PreTest(typeof(MappedCustomer), typeof(MappedOrder)); + + try + { + FluentMapper.Initialize(configuration => + { + configuration.AddMap(new MappedCustomerMap()); + configuration.AddMap(new MappedOrderMap()); + }); + + using (var reader = CreateReader( + CreateTable( + new[] { "customer_id", "customer_name" }, + new object[] { 1, "Ada" }, + new object[] { 2, "Grace" }), + CreateTable( + new[] { "order_id", "total" }, + new object[] { 10, 12.5m }))) + using (var multi = new MappedGridReader(reader)) + { + var customers = multi.ReadMapped().ToList(); + var orders = multi.ReadMapped().ToList(); + + Assert.Collection( + customers, + first => + { + Assert.Equal(1, first.Id); + Assert.Equal("Ada", first.Name); + }, + second => + { + Assert.Equal(2, second.Id); + Assert.Equal("Grace", second.Name); + }); + Assert.Collection( + orders, + order => + { + Assert.Equal(10, order.Id); + Assert.Equal(12.5m, order.Total); + }); + Assert.True(multi.IsConsumed); + } + } + finally + { + PreTest(typeof(MappedCustomer), typeof(MappedOrder)); + } + } + + [Fact] + public void ReadMappedShouldUseProfileForCurrentResultSet() + { + PreTest(typeof(MappedCustomer)); + + try + { + FluentMapper.Initialize(configuration => configuration.AddProfile()); + + using (var reader = CreateReader(CreateTable( + new[] { "legacy_id", "legal_name" }, + new object[] { 7, "Legacy Ltd." }))) + using (var multi = new MappedGridReader(reader)) + { + var customer = multi.ReadMapped().Single(); + + Assert.Equal(7, customer.Id); + Assert.Equal("Legacy Ltd.", customer.Name); + } + } + finally + { + PreTest(typeof(MappedCustomer)); + } + } + + [Fact] + public void ReadMappedShouldReturnEmptyCollectionForEmptyResultSet() + { + PreTest(typeof(MappedCustomer)); + + try + { + FluentMapper.Initialize(configuration => configuration.AddMap(new MappedCustomerMap())); + + using (var reader = CreateReader(CreateTable(new[] { "customer_id", "customer_name" }))) + using (var multi = new MappedGridReader(reader)) + { + var customers = multi.ReadMapped().ToList(); + + Assert.Empty(customers); + Assert.True(multi.IsConsumed); + } + } + finally + { + PreTest(typeof(MappedCustomer)); + } + } + + [Fact] + public void ReadMappedShouldThrowAfterFinalResultSet() + { + PreTest(typeof(MappedCustomer)); + + try + { + FluentMapper.Initialize(configuration => configuration.AddMap(new MappedCustomerMap())); + + using (var reader = CreateReader(CreateTable( + new[] { "customer_id", "customer_name" }, + new object[] { 1, "Ada" }))) + using (var multi = new MappedGridReader(reader)) + { + Assert.Single(multi.ReadMapped()); + + var exception = Assert.Throws( + () => multi.ReadMapped()); + + Assert.Contains("no remaining result sets", exception.Message, StringComparison.OrdinalIgnoreCase); + } + } + finally + { + PreTest(typeof(MappedCustomer)); + } + } + + [Fact] + public void ReadMappedShouldThrowAfterDispose() + { + using (var reader = CreateReader(CreateTable(new[] { "Id" }, new object[] { 1 }))) + { + var multi = new MappedGridReader(reader); + + multi.Dispose(); + + Assert.Throws(() => multi.ReadMapped()); + } + } + + [Fact] + public void DisposeAfterPartialConsumptionShouldCloseReader() + { + PreTest(typeof(MappedCustomer), typeof(MappedOrder)); + + try + { + FluentMapper.Initialize(configuration => + { + configuration.AddMap(new MappedCustomerMap()); + configuration.AddMap(new MappedOrderMap()); + }); + + var reader = CreateReader( + CreateTable( + new[] { "customer_id", "customer_name" }, + new object[] { 1, "Ada" }), + CreateTable( + new[] { "order_id", "total" }, + new object[] { 10, 12.5m })); + var multi = new MappedGridReader(reader); + + Assert.Single(multi.ReadMapped()); + + multi.Dispose(); + + Assert.True(reader.IsClosed); + Assert.True(multi.IsConsumed); + Assert.Throws(() => multi.ReadMapped()); + } + finally + { + PreTest(typeof(MappedCustomer), typeof(MappedOrder)); + } + } + + [Fact] + public void ReadMappedShouldDisposeReaderWhenMaterializationThrows() + { + PreTest(typeof(ThrowingCustomer)); + + try + { + FluentMapper.Initialize(configuration => configuration.AddMap(new ThrowingCustomerMap())); + + var reader = CreateReader(CreateTable( + new[] { "customer_id", "cpf" }, + new object[] { 1, string.Empty })); + var multi = new MappedGridReader(reader); + + var exception = Assert.Throws( + () => multi.ReadMapped()); + + Assert.IsType(exception.InnerException); + Assert.True(reader.IsClosed); + Assert.True(multi.IsConsumed); + Assert.Throws(() => multi.ReadMapped()); + } + finally + { + PreTest(typeof(ThrowingCustomer)); + } + } + + [Fact] + public void ReadMappedShouldUseGeneratedMaterializerWhenRegistered() + { + PreTest(typeof(MappedCustomer)); + + try + { + FluentMapper.Initialize(configuration => + { + configuration.AddMap(new MappedCustomerMap()); + configuration.AddGeneratedMaterializer( + new[] + { + GeneratedMaterializerColumn.Map("customer_id", nameof(MappedCustomer.Id)), + GeneratedMaterializerColumn.Map("customer_name", nameof(MappedCustomer.Name)) + }, + record => new MappedCustomer + { + Id = Convert.ToInt32(record.GetValue(0)), + Name = "generated:" + Convert.ToString(record.GetValue(1)) + }); + }); + + using (var reader = CreateReader(CreateTable( + new[] { "customer_id", "customer_name" }, + new object[] { 3, "Ada" }))) + using (var multi = new MappedGridReader(reader)) + { + var customer = multi.ReadMapped().Single(); + + Assert.Equal(3, customer.Id); + Assert.Equal("generated:Ada", customer.Name); + Assert.Equal(0, FluentMapper.Registry.MaterializationPlanCacheEntryCount); + } + } + finally + { + PreTest(typeof(MappedCustomer)); + } + } + + [Fact] + [Trait("Category", "Integration")] + public void QueryMultipleMappedShouldPassCommandParameters() + { + PreTest(typeof(MappedCustomer)); + + try + { + FluentMapper.Initialize(configuration => configuration.AddMap(new MappedCustomerMap())); + + using (var connection = OpenConnection()) + using (var multi = connection.QueryMultipleMapped( + "SELECT @id AS customer_id, @name AS customer_name;", + new { id = 5, name = "Katherine" })) + { + var customer = multi.ReadMapped().Single(); + + Assert.Equal(5, customer.Id); + Assert.Equal("Katherine", customer.Name); + } + } + finally + { + PreTest(typeof(MappedCustomer)); + } + } + + [Fact] + [Trait("Category", "Integration")] + public void QueryMultipleMappedShouldPropagateTransaction() + { + PreTest(typeof(MappedCustomer)); + + try + { + FluentMapper.Initialize(configuration => configuration.AddMap(new MappedCustomerMap())); + + using (var connection = OpenConnection()) + { + connection.Execute("CREATE TABLE customers (customer_id INTEGER NOT NULL, customer_name TEXT NOT NULL);"); + + using (var transaction = connection.BeginTransaction()) + { + connection.Execute( + "INSERT INTO customers (customer_id, customer_name) VALUES (9, 'Transaction');", + transaction: transaction); + + using (var multi = connection.QueryMultipleMapped( + "SELECT customer_id, customer_name FROM customers WHERE customer_id = @id;", + new { id = 9 }, + transaction)) + { + var customer = multi.ReadMapped().Single(); + + Assert.Equal(9, customer.Id); + Assert.Equal("Transaction", customer.Name); + } + + transaction.Rollback(); + } + } + } + finally + { + PreTest(typeof(MappedCustomer)); + } + } + + [Fact] + [Trait("Category", "Integration")] + public void QueryMultipleMappedShouldCloseConnectionItOpened() + { + using (var connection = new SqliteConnection("Data Source=:memory:")) + { + var multi = connection.QueryMultipleMapped("SELECT 1 AS Id;"); + + Assert.Equal(ConnectionState.Open, connection.State); + + multi.Dispose(); + + Assert.Equal(ConnectionState.Closed, connection.State); + } + } + + [Fact] + [Trait("Category", "Integration")] + public void QueryMultipleMappedShouldKeepOpenConnectionOpenAfterDispose() + { + using (var connection = OpenConnection()) + { + using (var multi = connection.QueryMultipleMapped("SELECT 1 AS Id;")) + { + Assert.Equal(ConnectionState.Open, connection.State); + } + + Assert.Equal(ConnectionState.Open, connection.State); + } + } + + private static DataTableReader CreateReader(params DataTable[] tables) + { + return new DataTableReader(tables); + } + + private static DataTable CreateTable(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; + } + + 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 DefaultEntity + { + public int Id { get; set; } + } + + private sealed class MappedCustomer + { + public int Id { get; set; } + + public string Name { get; set; } + } + + private sealed class MappedCustomerMap : EntityMap + { + public MappedCustomerMap() + { + Map(customer => customer.Id).ToColumn("customer_id"); + Map(customer => customer.Name).ToColumn("customer_name"); + } + } + + private sealed class LegacyCustomerMap : EntityMap, IProfileMap + { + public LegacyCustomerMap() + { + Map(customer => customer.Id).ToColumn("legacy_id"); + Map(customer => customer.Name).ToColumn("legal_name"); + } + } + + private sealed class MappedOrder + { + public int Id { get; set; } + + public decimal Total { get; set; } + } + + private sealed class MappedOrderMap : EntityMap + { + public MappedOrderMap() + { + Map(order => order.Id).ToColumn("order_id"); + Map(order => order.Total).ToColumn("total"); + } + } + + private sealed class ThrowingCustomer + { + public ThrowingCustomer(int id, ThrowingCpf cpf) + { + Id = id; + Cpf = cpf; + } + + public int Id { get; } + + public ThrowingCpf Cpf { get; } + } + + private sealed class ThrowingCustomerMap : EntityMap + { + public ThrowingCustomerMap() + { + Map(customer => customer.Id).ToColumn("customer_id"); + Map(customer => customer.Cpf.Number).ToColumn("cpf"); + } + } + + private sealed class ThrowingCpf + { + public ThrowingCpf(string number) + { + if (string.IsNullOrWhiteSpace(number)) + { + throw new ArgumentException("CPF cannot be empty.", nameof(number)); + } + + Number = number; + } + + public string Number { get; } + } + } +} From 1d707ccfb07624f8471548abec26ccb764ffbc9b Mon Sep 17 00:00:00 2001 From: Rodrigo de Oliveira Date: Tue, 28 Jul 2026 15:07:59 -0300 Subject: [PATCH 17/49] feat(query): support mapped multiple-result reads --- .sdd/etapa-9/01-historical-query-issues.md | 30 + .sdd/etapa-9/04-read-mapped-spec.md | 164 +++++ .sdd/etapa-9/STATUS.md | 32 +- README.md | 4 + src/Dapper.FluentMap/MappedGridReader.cs | 34 ++ .../QueryMultipleMappedTests.cs | 578 ++++++++++++++++++ 6 files changed, 841 insertions(+), 1 deletion(-) create mode 100644 .sdd/etapa-9/04-read-mapped-spec.md diff --git a/.sdd/etapa-9/01-historical-query-issues.md b/.sdd/etapa-9/01-historical-query-issues.md index c84921b..9a828d0 100644 --- a/.sdd/etapa-9/01-historical-query-issues.md +++ b/.sdd/etapa-9/01-historical-query-issues.md @@ -98,6 +98,21 @@ reader publico suficiente para reutilizar diretamente o materializador atual. deve falhar de forma previsivel. - Fechamento do reader/command quando o wrapper e descartado. +### Estado apos Prompt 9.3 + +Implementada regressao minima para o caminho opt-in atual: + +- `HistoricalIssue22ReadMappedShouldApplyConventionsAcrossMultipleResultSets` + configura a mesma convencao por entidade e le dois result sets sequenciais + com `ReadMappedSingle()`; +- a convencao e aplicada de forma independente para `ConventionCustomer` e + `ConventionOrder`; +- o teste usa `DataTableReader` para tornar os grids deterministicos e + provider-independent. + +O teste cobre `QueryMultipleMapped(...).ReadMapped*`, nao altera nem substitui +o comportamento Dapper puro de `QueryMultiple(...).Read()`. + ## Issue #43 ### Problema original @@ -179,3 +194,18 @@ publico, essa alternativa nao deve ser o caminho principal. `FluentMapConfigurationException`. - Materializacao de scalar result set deve permanecer via Dapper ou API explicitamente fora do FluentMap advanced materialization. + +### Estado apos Prompt 9.3 + +Implementada regressao minima para o caminho opt-in atual: + +- `HistoricalIssue43ReadMappedShouldApplyExplicitMapOnLaterResultSet` le tres + result sets sequenciais; +- o terceiro result set usa colunas equivalentes ao relato historico: + `column_prefix`, `column_name`, `display_order`, `can_be_ordered`, + `can_be_filtered` e `column_width_in_pixels`; +- o resultado prova que um grid posterior nao perde o `EntityMap` explicito. + +O teste modela os grids escalares/dinamicos historicos como pequenas entidades +mapeadas, porque `MappedGridReader` e deliberadamente uma API de +materializacao de entidades e nao uma substituicao geral para `GridReader`. diff --git a/.sdd/etapa-9/04-read-mapped-spec.md b/.sdd/etapa-9/04-read-mapped-spec.md new file mode 100644 index 0000000..1665d67 --- /dev/null +++ b/.sdd/etapa-9/04-read-mapped-spec.md @@ -0,0 +1,164 @@ +# ReadMapped and Profiles Specification + +Prompt executado em 2026-07-28. + +## Objetivo + +`QueryMultipleMapped` expoe materializacao FluentMap por result set, usando o +mesmo caminho de selecao ja usado por `QueryMapped*`. + +APIs publicas: + +```csharp +using var multi = connection.QueryMultipleMapped(sql); + +var customers = multi.ReadMapped(); +var legacyCustomers = multi.ReadMapped(); +var singleCustomer = multi.ReadMappedSingle(); +var singleLegacyCustomer = multi.ReadMappedSingle(); +``` + +Nao foi adicionada variante `ReadMappedSingleOrDefault`, porque a API existente +do projeto possui `QueryMappedSingle*`, mas nao possui +`QueryMappedSingleOrDefault*`. Essa decisao evita multiplicar superficie publica +sem contrato equivalente no restante do FluentMap. + +## Mapping e profiles + +Cada chamada de leitura resolve o mapping para o result set atual: + +- `ReadMapped()` usa o mapping default da entidade; +- `ReadMapped()` usa o profile registrado para a entidade; +- profiles nao alteram o type map global do Dapper; +- default map e profile map podem ser lidos para a mesma entidade em grids + diferentes sem colisao de cache ou materializer. + +Profile ausente falha com `FluentMapConfigurationException`, preservando o +comportamento de `QueryMapped()`. + +## Buffering + +Todas as APIs `ReadMapped*` sao buffered. + +Mesmo retornando `IEnumerable`, `ReadMapped()` le todo o grid +atual em memoria antes de retornar e chama `IDataReader.NextResult()` para +posicionar o wrapper no proximo result set. + +Nao ha streaming/unbuffered neste incremento. + +## Empty results + +`ReadMapped()` e `ReadMapped()` retornam uma colecao +vazia quando o grid atual nao tem linhas. + +`ReadMappedSingle()` e +`ReadMappedSingle()` seguem a semantica de LINQ `Single()`: + +- zero linhas: `InvalidOperationException`; +- uma linha: retorna a entidade; +- mais de uma linha: `InvalidOperationException`. + +Como o grid e buffered antes da aplicacao de `Single()`, o wrapper ja avancou +para o proximo result set quando a excecao de cardinalidade e observada. + +## Null semantics + +As regras sao as mesmas do runtime materializer usado por `QueryMapped*`: + +- subtree nested fica `null` quando todas as colunas mapeadas da subtree sao + `DBNull`; +- subtree e criada quando ao menos uma coluna da subtree tem valor; +- Value Object nullable recebe `null` quando suas colunas mapeadas sao todas + `DBNull`; +- `DBNull` para tipo valor nao nullable segue a conversao/default atual; +- `Ignore()` exclui a coluna da materializacao; +- metadata de escrita (`ReadOnly`, `Computed`, `DatabaseDefaultOnInsert`, + `ExcludeFromInsert`, `ExcludeFromUpdate`) nao muda leitura. + +## Nested objects, Value Objects e constructors + +`ReadMapped*` suporta os mesmos cenarios de `QueryMapped*`, porque ambos chamam +`MappedRowMaterializer`: + +- mapeamentos explicitos root-level; +- convencoes e naming policies; +- objetos aninhados settable; +- objetos aninhados imutaveis construidos por construtor publico compativel; +- Value Objects por componentes; +- construtores publicos de entidades imutaveis; +- fallback Dapper default para membros root-level nao configurados. + +Cenarios nao suportados, como caminho nested sem construtor publico compativel +ou construtores ambiguos, continuam falhando com `FluentMapConfigurationException`. + +## Exception behavior + +- `connection == null` em `QueryMultipleMapped` falha com + `ArgumentNullException`; +- `sql == null` em overload conveniente falha com `ArgumentNullException`; +- leitura apos `Dispose()` falha com `ObjectDisposedException`; +- leitura apos o ultimo result set falha com `InvalidOperationException`; +- falhas de configuracao/materializacao mantem o tipo de excecao do + materializer compartilhado; +- falhas durante materializacao ou `NextResult()` descartam o reader antes de + propagar a excecao. + +## Generated dispatch + +Para cada result set, o wrapper captura o shape ordenado de colunas e chama o +dispatcher compartilhado: + +```text +result set atual + | + v +column shape ordenado + | + v +TryGetGeneratedMaterializer(entity, profile, shape) + | + +--+--+ + | | + sim nao + | | +generated + runtime fallback +``` + +O lookup generated usa a chave: + +```text +EntityType + ProfileType opcional + ordered ColumnShape +``` + +Isso isola: + +```csharp +ReadMapped() +ReadMapped() +``` + +e tambem isola dois grids do mesmo tipo quando a ordem ou os nomes de colunas +mudam. + +## Runtime fallback + +Quando nao ha materializer gerado ou quando o descriptor gerado nao combina com +o mapping efetivo atual, o dispatcher chama +`MappingRegistry.GetMaterializationPlan(entity, profile, columns)`. + +A cache de plano runtime tambem inclui entity, profile e shape ordenado de +colunas, portanto `QueryMultipleMapped` nao possui um cache separado nem uma +segunda regra de selecao. + +## Historical regressions + +O Prompt 9.3 adicionou regressao minima para: + +- #22: convencoes aplicadas em multiplos result sets mapeados; +- #43: mapeamento explicito aplicado em um result set posterior, com colunas + equivalentes ao cenario historico de `DynamicTable.Column`. + +Esses testes cobrem o caminho opt-in `QueryMultipleMapped(...).ReadMapped*`. +O caminho Dapper puro `QueryMultiple(...).Read()` permanece fora da nova API +e continua dependente do type map global instalado pelo FluentMap. diff --git a/.sdd/etapa-9/STATUS.md b/.sdd/etapa-9/STATUS.md index c254a5c..af03b1f 100644 --- a/.sdd/etapa-9/STATUS.md +++ b/.sdd/etapa-9/STATUS.md @@ -54,6 +54,18 @@ equivalencia entre materializacao generated e runtime. set, profiles, empty result, invalid state, dispose antes/depois de consumo, excecao durante materializacao, generated materializer, parametros, transacao e lifetime de conexao. +- Prompt 9.3: criada especificacao + `.sdd/etapa-9/04-read-mapped-spec.md`. +- Prompt 9.3: adicionadas APIs `ReadMappedSingle()` e + `ReadMappedSingle()`, alinhadas a `QueryMappedSingle*`. +- Prompt 9.3: nao adicionada API `ReadMappedSingleOrDefault*`, pois nao ha + equivalente `QueryMappedSingleOrDefault*` na superficie atual do projeto. +- Prompt 9.3: reforcada cobertura de `ReadMapped*` para profiles isolados, + naming policy, convention, immutable objects, nested objects, Value Objects, + generated materializers, runtime fallback, equivalencia com `QueryMapped*` e + multiplos result sets de tipos diferentes. +- Prompt 9.3: adicionadas regressoes minimas para as issues historicas #22 e + #43 no caminho opt-in `QueryMultipleMapped(...).ReadMapped*`. ## Em andamento @@ -74,6 +86,24 @@ Nenhuma feature produtiva em andamento. `lib/netstandard2.0/Dapper.FluentMap.dll` e `lib/netstandard2.0/Dapper.FluentMap.xml` presentes. +## Validacao do Prompt 9.3 + +- `dotnet test test\Dapper.FluentMap.Tests\Dapper.FluentMap.Tests.csproj --configuration Release --filter FullyQualifiedName~QueryMultipleMappedTests`: + sucesso, 25 testes aprovados. +- `dotnet restore .\Dapper.FluentMap.sln`: sucesso. +- `dotnet build .\Dapper.FluentMap.sln --configuration Release --no-restore`: + sucesso, 0 warnings, 0 errors. +- `dotnet test .\Dapper.FluentMap.sln --configuration Release --no-build`: + sucesso, 323 testes aprovados no total. +- `dotnet pack .\src\Dapper.FluentMap\Dapper.FluentMap.csproj --configuration Release --no-build --output .\artifacts\packages`: + sucesso; warning legado `NU5125` sobre `licenseUrl`. +- Pacote inspecionado: + `lib/netstandard2.0/Dapper.FluentMap.dll` e + `lib/netstandard2.0/Dapper.FluentMap.xml` presentes. +- Benchmarks de smoke nao executados: o Prompt 9.3 nao alterou a regra de + dispatch, apenas reutilizou `MappedRowMaterializer` e adicionou wrappers + single sobre o caminho buffered existente. + ## Proximos passos 1. Unbuffered synchronous path. @@ -173,4 +203,4 @@ compatibilidade. ## Ultimo prompt executado -Ultimo prompt executado: 9.2 +Ultimo prompt executado: 9.3 diff --git a/README.md b/README.md index 2d4f2cf..ab7b205 100644 --- a/README.md +++ b/README.md @@ -367,6 +367,8 @@ var currentCustomers = multi.ReadMapped(); var legacyCustomers = multi.ReadMapped(); ``` +Use `ReadMappedSingle()` or `ReadMappedSingle()` when the current result set must contain exactly one row. + ## Diagnostics Use runtime validation to fail fast after configuration: @@ -903,6 +905,8 @@ var currentCustomers = multi.ReadMapped(); var legacyCustomers = multi.ReadMapped(); ``` +Use `ReadMappedSingle()` ou `ReadMappedSingle()` quando o result set atual deve conter exatamente uma linha. + ## Diagnósticos Use validação em runtime para falhar cedo depois da configuração: diff --git a/src/Dapper.FluentMap/MappedGridReader.cs b/src/Dapper.FluentMap/MappedGridReader.cs index 6668ce6..6886e8b 100644 --- a/src/Dapper.FluentMap/MappedGridReader.cs +++ b/src/Dapper.FluentMap/MappedGridReader.cs @@ -2,6 +2,7 @@ using System.Collections.Generic; using System.Data; using System.Diagnostics.CodeAnalysis; +using System.Linq; using Dapper.FluentMap.Materialization; using Dapper.FluentMap.Mapping; @@ -41,6 +42,21 @@ public IEnumerable ReadMapped< return ReadMapped(profileType: null); } + /// + /// Materializes exactly one row from the current result set and advances to the next one. + /// + /// The entity type to materialize. + /// The materialized row from the current result set. + [RequiresUnreferencedCode(QueryMappedApiAnnotations.RequiresUnreferencedCodeMessage)] + [RequiresDynamicCode(QueryMappedApiAnnotations.RequiresDynamicCodeMessage)] + public TEntity ReadMappedSingle< + [DynamicallyAccessedMembers(QueryMappedApiAnnotations.MaterializedEntityMemberTypes)] + TEntity>() + where TEntity : class + { + return ReadMapped().Single(); + } + /// /// Materializes the current result set using the specified FluentMap mapping profile and advances to the next one. /// @@ -59,6 +75,24 @@ public IEnumerable ReadMapped< return ReadMapped(typeof(TProfile)); } + /// + /// Materializes exactly one row from the current result set using the specified FluentMap mapping profile and advances to the next one. + /// + /// The entity type to materialize. + /// The mapping profile marker type to use. + /// The materialized row from the current result set. + [RequiresUnreferencedCode(QueryMappedApiAnnotations.RequiresUnreferencedCodeMessage)] + [RequiresDynamicCode(QueryMappedApiAnnotations.RequiresDynamicCodeMessage)] + public TEntity ReadMappedSingle< + [DynamicallyAccessedMembers(QueryMappedApiAnnotations.MaterializedEntityMemberTypes)] + TEntity, + TProfile>() + where TEntity : class + where TProfile : IMappingProfile + { + return ReadMapped().Single(); + } + /// /// Releases the underlying data reader. /// diff --git a/test/Dapper.FluentMap.Tests/QueryMultipleMappedTests.cs b/test/Dapper.FluentMap.Tests/QueryMultipleMappedTests.cs index 4a62d2a..27b45ab 100644 --- a/test/Dapper.FluentMap.Tests/QueryMultipleMappedTests.cs +++ b/test/Dapper.FluentMap.Tests/QueryMultipleMappedTests.cs @@ -2,8 +2,10 @@ using System.Data; using System.Linq; using Dapper; +using Dapper.FluentMap.Conventions; using Dapper.FluentMap.Mapping; using Dapper.FluentMap.Materialization; +using Dapper.FluentMap.Naming; using Microsoft.Data.Sqlite; using Xunit; @@ -97,6 +99,96 @@ public void ReadMappedShouldReadSequentialResultSets() } } + [Fact] + public void ReadMappedSingleShouldMaterializeExactlyOneRowAndAdvanceResultSet() + { + PreTest(typeof(MappedCustomer), typeof(MappedOrder)); + + try + { + FluentMapper.Initialize(configuration => + { + configuration.AddMap(new MappedCustomerMap()); + configuration.AddMap(new MappedOrderMap()); + }); + + using (var reader = CreateReader( + CreateTable( + new[] { "customer_id", "customer_name" }, + new object[] { 1, "Ada" }), + CreateTable( + new[] { "order_id", "total" }, + new object[] { 10, 12.5m }))) + using (var multi = new MappedGridReader(reader)) + { + var customer = multi.ReadMappedSingle(); + var order = multi.ReadMappedSingle(); + + Assert.Equal(1, customer.Id); + Assert.Equal("Ada", customer.Name); + Assert.Equal(10, order.Id); + Assert.Equal(12.5m, order.Total); + Assert.True(multi.IsConsumed); + } + } + finally + { + PreTest(typeof(MappedCustomer), typeof(MappedOrder)); + } + } + + [Fact] + public void ReadMappedSingleShouldUseProfileForCurrentResultSet() + { + PreTest(typeof(MappedCustomer)); + + try + { + FluentMapper.Initialize(configuration => configuration.AddProfile()); + + using (var reader = CreateReader(CreateTable( + new[] { "legacy_id", "legal_name" }, + new object[] { 7, "Legacy Ltd." }))) + using (var multi = new MappedGridReader(reader)) + { + var customer = multi.ReadMappedSingle(); + + Assert.Equal(7, customer.Id); + Assert.Equal("Legacy Ltd.", customer.Name); + Assert.True(multi.IsConsumed); + } + } + finally + { + PreTest(typeof(MappedCustomer)); + } + } + + [Fact] + public void ReadMappedSingleShouldThrowForEmptyResultSet() + { + PreTest(typeof(MappedCustomer)); + + try + { + FluentMapper.Initialize(configuration => configuration.AddMap(new MappedCustomerMap())); + + using (var reader = CreateReader(CreateTable(new[] { "customer_id", "customer_name" }))) + using (var multi = new MappedGridReader(reader)) + { + var exception = Assert.Throws( + () => multi.ReadMappedSingle()); + + Assert.Contains("no elements", exception.Message, StringComparison.OrdinalIgnoreCase); + Assert.True(multi.IsConsumed); + } + } + finally + { + PreTest(typeof(MappedCustomer)); + } + } + [Fact] public void ReadMappedShouldUseProfileForCurrentResultSet() { @@ -123,6 +215,129 @@ public void ReadMappedShouldUseProfileForCurrentResultSet() } } + [Fact] + public void ReadMappedShouldKeepDefaultAndProfileResultSetsIsolated() + { + PreTest(typeof(MappedCustomer)); + + try + { + FluentMapper.Initialize(configuration => + { + configuration.AddMap(new MappedCustomerMap()); + configuration.AddProfile(); + }); + + using (var reader = CreateReader( + CreateTable( + new[] { "customer_id", "customer_name" }, + new object[] { 1, "Default" }), + CreateTable( + new[] { "legacy_id", "legal_name" }, + new object[] { 2, "Legacy" }))) + using (var multi = new MappedGridReader(reader)) + { + var defaultCustomer = multi.ReadMappedSingle(); + var legacyCustomer = multi.ReadMappedSingle(); + + Assert.Equal(1, defaultCustomer.Id); + Assert.Equal("Default", defaultCustomer.Name); + Assert.Equal(2, legacyCustomer.Id); + Assert.Equal("Legacy", legacyCustomer.Name); + Assert.Equal(2, FluentMapper.Registry.MaterializationPlanCacheEntryCount); + } + } + finally + { + PreTest(typeof(MappedCustomer)); + } + } + + [Fact] + public void ReadMappedShouldApplyNamingPolicyAndConventionInCurrentResultSet() + { + PreTest(typeof(PolicyConventionCustomer)); + + try + { + FluentMapper.Initialize(configuration => + { + configuration.UseNamingPolicy(NamingPolicy.SnakeCase, caseSensitive: false).ForEntity(); + configuration.AddConvention().ForEntity(); + }); + + using (var reader = CreateReader(CreateTable( + new[] { "CUSTOMER_ID", "legal_name" }, + new object[] { 8, "Policy" }))) + using (var multi = new MappedGridReader(reader)) + { + var customer = multi.ReadMappedSingle(); + + Assert.Equal(8, customer.CustomerId); + Assert.Equal("Policy", customer.Name); + } + } + finally + { + PreTest(typeof(PolicyConventionCustomer)); + } + } + + [Fact] + public void ReadMappedShouldMaterializeImmutableNestedObjectsAndValueObjects() + { + PreTest(typeof(ComplexCustomer)); + + try + { + FluentMapper.Initialize(configuration => configuration.AddMap(new ComplexCustomerMap())); + + using (var reader = CreateReader(CreateTable( + new[] { "customer_id", "city", "email" }, + new object[] { 13, "Sao Paulo", "ada@example.com" }))) + using (var multi = new MappedGridReader(reader)) + { + var customer = multi.ReadMappedSingle(); + + Assert.Equal(13, customer.Id); + Assert.NotNull(customer.Address); + Assert.Equal("Sao Paulo", customer.Address.City); + Assert.Equal(new ComplexEmail("ada@example.com"), customer.Email); + } + } + finally + { + PreTest(typeof(ComplexCustomer)); + } + } + + [Fact] + public void ReadMappedShouldPreserveNestedNullSemantics() + { + PreTest(typeof(ComplexCustomer)); + + try + { + FluentMapper.Initialize(configuration => configuration.AddMap(new ComplexCustomerMap())); + + using (var reader = CreateReader(CreateTable( + new[] { "customer_id", "city", "email" }, + new object[] { 14, DBNull.Value, DBNull.Value }))) + using (var multi = new MappedGridReader(reader)) + { + var customer = multi.ReadMappedSingle(); + + Assert.Equal(14, customer.Id); + Assert.Null(customer.Address); + Assert.Null(customer.Email); + } + } + finally + { + PreTest(typeof(ComplexCustomer)); + } + } + [Fact] public void ReadMappedShouldReturnEmptyCollectionForEmptyResultSet() { @@ -293,6 +508,195 @@ public void ReadMappedShouldUseGeneratedMaterializerWhenRegistered() } } + [Fact] + public void ReadMappedShouldUseGeneratedProfileMaterializersWithoutCollisions() + { + PreTest(typeof(MappedCustomer)); + + try + { + FluentMapper.Initialize(configuration => + { + configuration.AddMap(new MappedCustomerMap()); + configuration.AddProfile(); + configuration.AddGeneratedMaterializer( + new[] + { + GeneratedMaterializerColumn.Map("customer_id", nameof(MappedCustomer.Id)), + GeneratedMaterializerColumn.Map("customer_name", nameof(MappedCustomer.Name)) + }, + record => new MappedCustomer + { + Id = Convert.ToInt32(record.GetValue(0)), + Name = "default-generated:" + Convert.ToString(record.GetValue(1)) + }); + configuration.AddGeneratedMaterializer( + new[] + { + GeneratedMaterializerColumn.Map("legacy_id", nameof(MappedCustomer.Id)), + GeneratedMaterializerColumn.Map("legal_name", nameof(MappedCustomer.Name)) + }, + record => new MappedCustomer + { + Id = Convert.ToInt32(record.GetValue(0)), + Name = "profile-generated:" + Convert.ToString(record.GetValue(1)) + }); + }); + + using (var reader = CreateReader( + CreateTable( + new[] { "customer_id", "customer_name" }, + new object[] { 3, "Default" }), + CreateTable( + new[] { "legacy_id", "legal_name" }, + new object[] { 4, "Legacy" }))) + using (var multi = new MappedGridReader(reader)) + { + var defaultCustomer = multi.ReadMappedSingle(); + var legacyCustomer = multi.ReadMappedSingle(); + + Assert.Equal("default-generated:Default", defaultCustomer.Name); + Assert.Equal("profile-generated:Legacy", legacyCustomer.Name); + Assert.Equal(0, FluentMapper.Registry.MaterializationPlanCacheEntryCount); + } + } + finally + { + PreTest(typeof(MappedCustomer)); + } + } + + [Fact] + public void ReadMappedGeneratedAndRuntimeShouldReturnEquivalentResultsForSameShape() + { + var generated = MaterializeGeneratedCustomer(registerGeneratedMaterializer: true); + var runtime = MaterializeGeneratedCustomer(registerGeneratedMaterializer: false); + + Assert.Equal(generated.Id, runtime.Id); + Assert.Equal(generated.Name, runtime.Name); + } + + [Fact] + [Trait("Category", "Integration")] + public void QueryMappedAndReadMappedShouldReturnEquivalentResults() + { + PreTest(typeof(MappedCustomer)); + + try + { + FluentMapper.Initialize(configuration => configuration.AddMap(new MappedCustomerMap())); + + using (var connection = OpenConnection()) + { + const string sql = "SELECT 21 AS customer_id, 'Equivalent' AS customer_name;"; + + var queryMapped = connection.QueryMappedSingle(sql); + using (var multi = connection.QueryMultipleMapped(sql)) + { + var readMapped = multi.ReadMappedSingle(); + + Assert.Equal(queryMapped.Id, readMapped.Id); + Assert.Equal(queryMapped.Name, readMapped.Name); + } + } + } + finally + { + PreTest(typeof(MappedCustomer)); + } + } + + [Fact] + public void HistoricalIssue22ReadMappedShouldApplyConventionsAcrossMultipleResultSets() + { + PreTest(typeof(ConventionCustomer), typeof(ConventionOrder)); + + try + { + FluentMapper.Initialize(configuration => + { + configuration.AddConvention().ForEntity(); + configuration.AddConvention().ForEntity(); + }); + + using (var reader = CreateReader( + CreateTable( + new[] { "colId", "colName" }, + new object[] { 1, "Ada" }), + CreateTable( + new[] { "colId", "colTotal" }, + new object[] { 20, 99.5m }))) + using (var multi = new MappedGridReader(reader)) + { + var customer = multi.ReadMappedSingle(); + var order = multi.ReadMappedSingle(); + + Assert.Equal(1, customer.Id); + Assert.Equal("Ada", customer.Name); + Assert.Equal(20, order.Id); + Assert.Equal(99.5m, order.Total); + } + } + finally + { + PreTest(typeof(ConventionCustomer), typeof(ConventionOrder)); + } + } + + [Fact] + public void HistoricalIssue43ReadMappedShouldApplyExplicitMapOnLaterResultSet() + { + PreTest(typeof(HistoricalRow), typeof(HistoricalTotal), typeof(HistoricalColumn)); + + try + { + FluentMapper.Initialize(configuration => + { + configuration.AddMap(new HistoricalRowMap()); + configuration.AddMap(new HistoricalTotalMap()); + configuration.AddMap(new HistoricalColumnMap()); + }); + + using (var reader = CreateReader( + CreateTable( + new[] { "row_no" }, + new object[] { 1 }), + CreateTable( + new[] { "total" }, + new object[] { 1 }), + CreateTable( + new[] + { + "column_prefix", + "column_name", + "display_order", + "can_be_ordered", + "can_be_filtered", + "column_width_in_pixels" + }, + new object[] { "usr", "name", 2, true, false, 160 }))) + using (var multi = new MappedGridReader(reader)) + { + var row = multi.ReadMappedSingle(); + var total = multi.ReadMappedSingle(); + var column = multi.ReadMappedSingle(); + + Assert.Equal(1, row.RowNo); + Assert.Equal(1, total.Total); + Assert.Equal("usr", column.Prefix); + Assert.Equal("name", column.Name); + Assert.Equal(2, column.DisplayOrder); + Assert.True(column.CanBeOrdered); + Assert.False(column.CanBeFiltered); + Assert.Equal(160, column.WidthInPixels); + } + } + finally + { + PreTest(typeof(HistoricalRow), typeof(HistoricalTotal), typeof(HistoricalColumn)); + } + } + [Fact] [Trait("Category", "Integration")] public void QueryMultipleMappedShouldPassCommandParameters() @@ -420,6 +824,48 @@ private static SqliteConnection OpenConnection() return connection; } + private static MappedCustomer MaterializeGeneratedCustomer(bool registerGeneratedMaterializer) + { + PreTest(typeof(MappedCustomer)); + + try + { + FluentMapper.Initialize(configuration => + { + configuration.AddMap(new MappedCustomerMap()); + + if (registerGeneratedMaterializer) + { + configuration.AddGeneratedMaterializer( + new[] + { + GeneratedMaterializerColumn.Map("customer_id", nameof(MappedCustomer.Id)), + GeneratedMaterializerColumn.Map("customer_name", nameof(MappedCustomer.Name)) + }, + record => new MappedCustomer + { + Id = Convert.ToInt32(record.GetValue(0)), + Name = Convert.ToString(record.GetValue(1)) + }); + } + }); + + using (var reader = CreateReader(CreateTable( + new[] { "customer_id", "customer_name" }, + new object[] { 31, "Same Shape" }))) + using (var multi = new MappedGridReader(reader)) + { + var customer = multi.ReadMappedSingle(); + Assert.Equal(registerGeneratedMaterializer ? 0 : 1, FluentMapper.Registry.MaterializationPlanCacheEntryCount); + return customer; + } + } + finally + { + PreTest(typeof(MappedCustomer)); + } + } + private static void PreTest(params Type[] types) { FluentMapper.Reset(types); @@ -475,6 +921,138 @@ public MappedOrderMap() } } + private sealed class PolicyConventionCustomer + { + public int CustomerId { get; set; } + + public string Name { get; set; } + } + + private sealed class LegalNameConvention : Convention + { + public LegalNameConvention() + { + Properties() + .Where(property => property.Name == nameof(PolicyConventionCustomer.Name)) + .Configure(property => property.HasColumnName("legal_name")); + } + } + + private sealed class ComplexCustomer + { + public ComplexCustomer(int id, ComplexAddress address, ComplexEmail email) + { + Id = id; + Address = address; + Email = email; + } + + public int Id { get; } + + public ComplexAddress Address { get; } + + public ComplexEmail Email { get; } + } + + private sealed class ComplexAddress + { + public ComplexAddress(string city) + { + City = city; + } + + public string City { get; } + } + + private sealed record ComplexEmail(string Value); + + private sealed class ComplexCustomerMap : EntityMap + { + public ComplexCustomerMap() + { + Map(customer => customer.Id).ToColumn("customer_id"); + Map(customer => customer.Address.City).ToColumn("city"); + Map(customer => customer.Email.Value).ToColumn("email"); + } + } + + private sealed class ConventionCustomer + { + public int Id { get; set; } + + public string Name { get; set; } + } + + private sealed class ConventionOrder + { + public int Id { get; set; } + + public decimal Total { get; set; } + } + + private sealed class ColumnPrefixConvention : Convention + { + public ColumnPrefixConvention() + { + Properties() + .Configure(property => property.HasPrefix("col")); + } + } + + private sealed class HistoricalRow + { + public int RowNo { get; set; } + } + + private sealed class HistoricalRowMap : EntityMap + { + public HistoricalRowMap() + { + Map(row => row.RowNo).ToColumn("row_no"); + } + } + + private sealed class HistoricalTotal + { + public int Total { get; set; } + } + + private sealed class HistoricalTotalMap : EntityMap + { + public HistoricalTotalMap() + { + Map(total => total.Total).ToColumn("total"); + } + } + + private sealed class HistoricalColumn + { + public string Prefix { get; set; } + + public string Name { get; set; } + + public int DisplayOrder { get; set; } + + public bool CanBeOrdered { get; set; } + + public bool CanBeFiltered { get; set; } + + public int WidthInPixels { get; set; } + } + + private sealed class HistoricalColumnMap : EntityMap + { + public HistoricalColumnMap() + { + Map(column => column.Prefix).ToColumn("column_prefix"); + Map(column => column.Name).ToColumn("column_name"); + Map(column => column.DisplayOrder).ToColumn("display_order"); + Map(column => column.CanBeOrdered).ToColumn("can_be_ordered"); + Map(column => column.CanBeFiltered).ToColumn("can_be_filtered"); + Map(column => column.WidthInPixels).ToColumn("column_width_in_pixels"); + } + } + private sealed class ThrowingCustomer { public ThrowingCustomer(int id, ThrowingCpf cpf) From 80d1aba82e91709ca4b3ab33746476b7bbce9e2c Mon Sep 17 00:00:00 2001 From: Rodrigo de Oliveira Date: Tue, 28 Jul 2026 15:29:21 -0300 Subject: [PATCH 18/49] feat(query): add unbuffered mapped materialization --- .sdd/etapa-9/05-unbuffered-materialization.md | 235 +++++++ .sdd/etapa-9/06-performance-results.md | 88 +++ .sdd/etapa-9/DECISIONS.md | 33 + .sdd/etapa-9/STATUS.md | 63 +- README.md | 28 +- .../Dapper.FluentMap.Benchmarks/Program.cs | 28 + .../Materialization/MappedRowMaterializer.cs | 34 +- src/Dapper.FluentMap/QueryMappedExtensions.cs | 157 +++++ .../QueryMappedUnbufferedTests.cs | 589 ++++++++++++++++++ 9 files changed, 1229 insertions(+), 26 deletions(-) create mode 100644 .sdd/etapa-9/05-unbuffered-materialization.md create mode 100644 .sdd/etapa-9/06-performance-results.md create mode 100644 test/Dapper.FluentMap.Tests/QueryMappedUnbufferedTests.cs diff --git a/.sdd/etapa-9/05-unbuffered-materialization.md b/.sdd/etapa-9/05-unbuffered-materialization.md new file mode 100644 index 0000000..0a6f441 --- /dev/null +++ b/.sdd/etapa-9/05-unbuffered-materialization.md @@ -0,0 +1,235 @@ +# Synchronous Unbuffered Materialization + +Prompt executado em 2026-07-28. + +## Discovery do Dapper + +Dependencia efetiva confirmada no core: `Dapper` 2.1.79. + +Superficie publica relevante confirmada no pacote instalado: + +- `SqlMapper.Query(IDbConnection, string, ..., bool buffered = true, ...)`; +- `SqlMapper.Query(IDbConnection, CommandDefinition)`; +- `CommandDefinition` possui `CommandFlags`, com `Buffered = 1` e `None = 0`; +- `SqlMapper.ExecuteReader(IDbConnection, CommandDefinition)`; +- `SqlMapper.ExecuteReader(IDbConnection, CommandDefinition, CommandBehavior)`; +- `SqlMapper.QueryUnbufferedAsync(...)` existe para caminho async; +- `GridReader.ReadUnbufferedAsync*` existe para multiple result sets async. + +Nao ha API publica sincronica chamada `QueryUnbuffered` no Dapper 2.1.79. O +caminho sincronico unbuffered do Dapper e exposto pela convencao +`Query(..., buffered: false)` ou por `CommandDefinition` sem +`CommandFlags.Buffered`. + +O FluentMap nao usa `Dapper.Query` para a nova API porque precisa aplicar seu +proprio materializador por `IDataRecord`, incluindo nested objects, Value +Objects, profiles e generated materializers. A execucao continua baseada em +API publica do Dapper: `SqlMapper.ExecuteReader`. + +## API + +APIs publicas adicionadas: + +```csharp +IEnumerable QueryMappedUnbuffered( + this IDbConnection connection, + string sql, + object param = null, + IDbTransaction transaction = null, + int? commandTimeout = null, + CommandType? commandType = null) + where TEntity : class; + +IEnumerable QueryMappedUnbuffered( + this IDbConnection connection, + string sql, + object param = null, + IDbTransaction transaction = null, + int? commandTimeout = null, + CommandType? commandType = null) + where TEntity : class + where TProfile : IMappingProfile; + +IEnumerable QueryMappedUnbuffered( + this IDbConnection connection, + CommandDefinition command) + where TEntity : class; + +IEnumerable QueryMappedUnbuffered( + this IDbConnection connection, + CommandDefinition command) + where TEntity : class + where TProfile : IMappingProfile; +``` + +O nome `Unbuffered` foi escolhido para nao alterar a semantica buffered de +`QueryMapped()` e para manter o lifetime perigoso visivel no call site. + +Os overloads convenientes criam `CommandDefinition` com `CommandFlags.None`. +Quando o usuario passa um `CommandDefinition`, o FluentMap preserva o comando +recebido. + +## Lazy execution + +`QueryMappedUnbuffered*` retorna uma sequencia lazy. + +Regras: + +- argumentos nulos sao validados na chamada publica; +- o comando nao e executado na chamada publica; +- o comando nao e executado por `GetEnumerator()` isolado; +- o comando e executado no primeiro `MoveNext()`; +- cada nova enumeracao executa um novo comando. + +Essa semantica e diferente de `QueryMapped()`, que executa e bufferiza antes +de retornar. + +## Reader lifetime + +O `IDataReader` fica aberto durante a enumeracao. + +Fluxo: + +1. primeiro `MoveNext()` chama `SqlMapper.ExecuteReader`; +2. o FluentMap captura o shape ordenado de colunas; +3. o FluentMap escolhe o materializer uma vez; +4. cada `MoveNext()` materializa uma linha; +5. fim da enumeracao, early break, dispose explicito ou excecao descartam o + reader. + +O enumerator deve ser descartado quando a enumeracao parar cedo. `foreach` +cumpre esse contrato automaticamente. + +## Connection lifetime + +O FluentMap nao assume ownership da conexao recebida. + +Regra preservada: + +- conexao inicialmente fechada: Dapper/ADO.NET abre no primeiro `MoveNext()` e + o dispose do reader fecha ao final, early break, dispose explicito ou excecao; +- conexao inicialmente aberta: permanece aberta apos a enumeracao ou apos o + dispose do enumerator. + +O usuario e responsavel por manter uma conexao externa aberta e valida durante +toda a enumeracao unbuffered. + +## Early enumeration termination + +Parar a enumeracao antes do fim descarta o reader quando o enumerator e +descartado. + +Exemplos seguros: + +```csharp +foreach (var row in connection.QueryMappedUnbuffered(sql)) +{ + break; +} +``` + +```csharp +using var enumerator = connection.QueryMappedUnbuffered(sql).GetEnumerator(); +if (enumerator.MoveNext()) +{ + // process one row +} +``` + +## Exception behavior + +Excecoes de materializacao preservam a semantica existente: + +- profile ausente falha com `FluentMapConfigurationException`; +- construtor ou regra de dominio que falha durante materializacao continua + sendo encapsulado pelo runtime materializer quando esse e o comportamento + atual; +- excecoes de ADO.NET/Dapper durante execucao/leitura nao sao convertidas para + excecoes de configuracao. + +Quando uma excecao ocorre no meio da enumeracao, o iterator descarta o reader +antes de propagar a excecao. + +## Generated materializer + +O lookup generated continua por: + +```text +EntityType + ProfileType opcional + ordered ColumnShape +``` + +No caminho unbuffered, o lookup ocorre uma vez por enumeracao, depois que o +reader esta aberto e antes do loop de linhas. Linhas subsequentes chamam apenas +o delegate escolhido. + +Generated materializers continuam sendo otimizacao. Eles nao executam SQL, nao +abrem conexao, nao avancam reader e nao possuem recursos. + +## Runtime fallback + +Quando nao ha descriptor generated compativel, o FluentMap usa o mesmo +`NestedMaterializationPlan` de `QueryMapped*`. + +O plano tambem e resolvido uma vez antes do loop de linhas. A cache existente +continua indexada por tipo, profile e shape ordenado de colunas. + +## Allocation expectations + +O caminho unbuffered evita a alocacao da `List` usada por +`QueryMapped()`. + +Ainda ha alocacoes esperadas para: + +- o objeto enumerator; +- o `IDataReader`/command do provider; +- o array de nomes de colunas por enumeracao; +- o delegate/wrapper de materializer por enumeracao; +- as entidades e objetos aninhados/value objects materializados; +- caches runtime no primeiro fallback por shape. + +Nao ha promessa publica de throughput ou latencia. Resultados locais ficam em +`.sdd/etapa-9/06-performance-results.md`. + +## Profiles + +Profiles sao selecionados por operacao: + +```csharp +connection.QueryMappedUnbuffered(sql); +``` + +Isso nao altera type maps globais do Dapper. Profile default e profile +especifico usam chaves de cache separadas. + +## Transaction + +O parametro `transaction` dos overloads convenientes e encaminhado para +`CommandDefinition`. + +Como a enumeracao e lazy, a transacao precisa continuar ativa ate a enumeracao +terminar. Descartar ou concluir a transacao antes de enumerar e erro de uso do +chamador/provider. + +## Ownership + +O FluentMap e dono do reader que abre por `SqlMapper.ExecuteReader` durante a +enumeracao. + +O FluentMap nao e dono: + +- da conexao recebida; +- da transacao recebida; +- dos parametros; +- do SQL; +- do ciclo de vida externo que envolve a enumeracao. + +## Escopo excluido + +Este prompt nao implementa: + +- async streaming; +- `IAsyncEnumerable`; +- `ReadMappedUnbuffered()` em `MappedGridReader`; +- suporte a `SqlMapper.GridReader` internals; +- multi-mapping por `splitOn`; +- SQL generation ou query builder. diff --git a/.sdd/etapa-9/06-performance-results.md b/.sdd/etapa-9/06-performance-results.md new file mode 100644 index 0000000..a5b50cc --- /dev/null +++ b/.sdd/etapa-9/06-performance-results.md @@ -0,0 +1,88 @@ +# Etapa 9 Performance Results + +Prompt executado em 2026-07-28. + +## Baseline herdada da Etapa 7 + +Rodada final da Etapa 7, `ShortRun`, 1000 linhas por operacao: + +| Scenario | Allocated | +| --- | ---: | +| Dapper puro buffered | 283.17 KB | +| Dapper + FluentMap root mapping buffered | 283.3 KB | +| QueryMapped simple generated buffered | 261.12 KB | +| QueryMapped simple runtime fallback buffered | 361.48 KB | +| QueryMapped nested generated buffered | 292.44 KB | +| QueryMapped nested runtime fallback buffered | 377.06 KB | +| QueryMapped Value Object generated buffered | 276.47 KB | +| QueryMapped Value Object runtime fallback buffered | 587.9 KB | + +Leitura da baseline: o ganho mais estavel da Etapa 7 estava em alocacao no +caminho generated. O tempo local era ruidoso e nao foi tratado como promessa de +latencia. + +## Benchmarks adicionados no Prompt 9.4 + +O benchmark steady state passou a comparar no mesmo dataset: + +- Dapper buffered; +- Dapper unbuffered (`buffered: false`); +- FluentMap `QueryMapped` buffered; +- FluentMap `QueryMappedUnbuffered` generated; +- FluentMap `QueryMapped` runtime fallback; +- FluentMap `QueryMappedUnbuffered` runtime fallback. + +Comando representativo: + +```bash +dotnet run --project benchmarks/Dapper.FluentMap.Benchmarks/Dapper.FluentMap.Benchmarks.csproj --configuration Release -- --filter *MaterializationSteadyStateBenchmarks* +``` + +## Resultados do Prompt 9.4 + +Comando executado: + +```bash +dotnet run --project benchmarks\Dapper.FluentMap.Benchmarks\Dapper.FluentMap.Benchmarks.csproj --configuration Release --no-build -- --filter *MaterializationSteadyStateBenchmarks* +``` + +Ambiente reportado pelo BenchmarkDotNet: + +- Windows 11 `10.0.26200.8875/25H2/2025Update/HudsonValley2`; +- CPU: 11th Gen Intel Core i5-1145G7; +- .NET SDK: `10.0.302`; +- Runtime: `.NET 10.0.10`; +- BenchmarkDotNet: `0.15.8`; +- Job: `ShortRun`, 1000 linhas por operacao. + +| Method | Mean | StdDev | Gen0 | Gen1 | Allocated | Alloc Ratio | +| --- | ---: | ---: | ---: | ---: | ---: | ---: | +| DapperWithFluentMapRootMapping | 1.741 ms | 0.1655 ms | 66.4063 | - | 283.3 KB | 1.00 | +| QueryMappedNestedObjectRuntimeFallback | 1.775 ms | 0.1504 ms | 89.8438 | 27.3438 | 377.16 KB | 1.33 | +| QueryMappedSimpleUnbufferedRuntimeFallback | 1.796 ms | 0.0716 ms | 82.0313 | - | 345.48 KB | 1.22 | +| QueryMappedValueObject | 1.818 ms | 0.1290 ms | 62.5000 | 19.5313 | 276.5 KB | 0.98 | +| QueryMappedValueObjectRuntimeFallback | 1.865 ms | 0.2101 ms | 136.7188 | 25.3906 | 587.99 KB | 2.08 | +| QueryMappedSimpleRuntimeFallback | 1.911 ms | 0.1662 ms | 87.8906 | 19.5313 | 361.58 KB | 1.28 | +| QueryMappedNestedObject | 2.056 ms | 0.1918 ms | 70.3125 | 23.4375 | 292.47 KB | 1.03 | +| QueryMappedSimple | 2.098 ms | 0.1705 ms | 62.5000 | 11.7188 | 261.15 KB | 0.92 | +| DapperPureUnbuffered | 2.241 ms | 0.6324 ms | 62.5000 | - | 266.96 KB | 0.94 | +| QueryMappedImmutableConstructor | 2.432 ms | 0.2152 ms | 62.5000 | 11.7188 | 261.09 KB | 0.92 | +| DapperPure | 2.563 ms | 0.0961 ms | 62.5000 | - | 283.17 KB | 1.00 | +| QueryMappedSimpleUnbuffered | 2.633 ms | 0.2799 ms | 58.5938 | - | 245.17 KB | 0.87 | + +## Leitura + +- O tempo continua ruidoso no `ShortRun`; nao ha base para promessa publica de + throughput. +- A diferenca de alocacao confirma o efeito esperado do caminho unbuffered: + - Dapper puro: `266.96 KB` unbuffered vs `283.17 KB` buffered; + - FluentMap simple generated: `245.17 KB` unbuffered vs `261.15 KB` + buffered; + - FluentMap simple runtime fallback: `345.48 KB` unbuffered vs `361.58 KB` + buffered. +- A reducao e coerente com a remocao da `List` interna. O custo de + entidades, provider, reader, shape de colunas e materializacao por linha + permanece. +- Nested e Value Object unbuffered nao foram adicionados como metodos + separados nesta rodada para evitar crescimento excessivo da matriz; o + mecanismo e o mesmo, e a cobertura funcional exercita esses shapes. diff --git a/.sdd/etapa-9/DECISIONS.md b/.sdd/etapa-9/DECISIONS.md index 6957473..c4d62cd 100644 --- a/.sdd/etapa-9/DECISIONS.md +++ b/.sdd/etapa-9/DECISIONS.md @@ -300,3 +300,36 @@ Nao adicionar `QueryMultipleMappedAsync`, `IAsyncDisposable` ou A API publica nova e aditiva e alinhada ao nome `QueryMultiple` do Dapper. O primeiro incremento e buffered e sequencial. Streaming e async continuam decisoes separadas para prompts posteriores. + +## ADR-12 - QueryMappedUnbuffered sincrono + +### Contexto + +`QueryMapped*` bufferiza resultados em `List` antes de retornar. Isso +mantem lifetime simples, mas penaliza datasets grandes. Dapper 2.1.79 oferece +leitura sincronica unbuffered pela convencao `Query(..., buffered: false)`, +mas esse caminho nao aplica nested/value-object/profile/generated do FluentMap. + +### Decisao + +Adicionar APIs `QueryMappedUnbuffered` e +`QueryMappedUnbuffered` como caminhos lazy e explicitos. + +O FluentMap executa por `SqlMapper.ExecuteReader`, resolve o materializer uma +vez por shape e materializa uma linha por `MoveNext()`. O reader e descartado +quando a enumeracao termina, quando o enumerator e descartado ou quando uma +excecao interrompe o loop. + +### Alternativas consideradas + +- Tornar `QueryMapped` lazy: descartado por breaking change. +- Adicionar parametro `buffered` a `QueryMapped`: descartado por esconder + mudanca forte de lifetime em um booleano opcional. +- Usar `Dapper.Query(buffered: false)`: descartado porque nao aplica a + materializacao avancada do FluentMap. + +### Consequencias + +O usuario ganha processamento incremental sincrono sem async streaming. O +contrato exige que conexao/transacao externas permanecam validas durante a +enumeracao. Async streaming permanece para prompt futuro. diff --git a/.sdd/etapa-9/STATUS.md b/.sdd/etapa-9/STATUS.md index af03b1f..b413f38 100644 --- a/.sdd/etapa-9/STATUS.md +++ b/.sdd/etapa-9/STATUS.md @@ -66,6 +66,22 @@ equivalencia entre materializacao generated e runtime. multiplos result sets de tipos diferentes. - Prompt 9.3: adicionadas regressoes minimas para as issues historicas #22 e #43 no caminho opt-in `QueryMultipleMapped(...).ReadMapped*`. +- Prompt 9.4: criada especificacao + `.sdd/etapa-9/05-unbuffered-materialization.md`. +- Prompt 9.4: adicionada API `QueryMappedUnbuffered()`. +- Prompt 9.4: adicionada API `QueryMappedUnbuffered()`. +- Prompt 9.4: adicionados overloads por `CommandDefinition`. +- Prompt 9.4: refatorado `MappedRowMaterializer` para resolver o delegate de + materializacao uma vez por shape e reutilizar no caminho buffered e + unbuffered. +- Prompt 9.4: adicionada cobertura para flat entity, nested object, Value + Object, profile, generated materializer, runtime fallback, empty result, + large sequence, lazy execution, early break, dispose explicito, excecao no + meio da enumeracao, connection lifetime e transaction externa. +- Prompt 9.4: adicionados benchmarks de Dapper unbuffered e FluentMap + unbuffered generated/runtime fallback. +- Prompt 9.4: criada + `.sdd/etapa-9/06-performance-results.md` para baseline e resultados. ## Em andamento @@ -104,13 +120,33 @@ Nenhuma feature produtiva em andamento. dispatch, apenas reutilizou `MappedRowMaterializer` e adicionou wrappers single sobre o caminho buffered existente. +## Validacao do Prompt 9.4 + +- `dotnet test test\Dapper.FluentMap.Tests\Dapper.FluentMap.Tests.csproj --configuration Release --filter FullyQualifiedName~QueryMappedUnbufferedTests`: + sucesso, 14 testes aprovados. +- `dotnet build .\Dapper.FluentMap.sln --configuration Release`: + sucesso, 0 warnings, 0 errors. +- `dotnet run --project benchmarks\Dapper.FluentMap.Benchmarks\Dapper.FluentMap.Benchmarks.csproj --configuration Release --no-build -- --filter *MaterializationSteadyStateBenchmarks*`: + sucesso, 12 benchmarks executados; resultados registrados em + `.sdd/etapa-9/06-performance-results.md`. +- `dotnet restore .\Dapper.FluentMap.sln`: + sucesso. +- `dotnet build .\Dapper.FluentMap.sln --configuration Release --no-restore`: + sucesso, 0 warnings, 0 errors. +- `dotnet test .\Dapper.FluentMap.sln --configuration Release --no-build`: + sucesso, 337 testes aprovados no total. +- `dotnet pack .\src\Dapper.FluentMap\Dapper.FluentMap.csproj --configuration Release --no-build --output .\artifacts\packages`: + sucesso; warning legado `NU5125` sobre `licenseUrl`. +- Pacote inspecionado: + `lib/netstandard2.0/Dapper.FluentMap.dll` e + `lib/netstandard2.0/Dapper.FluentMap.xml` presentes. + ## Proximos passos -1. Unbuffered synchronous path. -2. Async streaming. -3. Lifetime/cancellation hardening para caminhos async/streaming. -4. Regression/performance. -5. Documentacao final. +1. Async streaming. +2. Lifetime/cancellation hardening para caminhos async/streaming. +3. Regression/performance complementar. +4. Documentacao final. ## Decisoes relevantes @@ -121,6 +157,8 @@ Nenhuma feature produtiva em andamento. - Prompt 9.2 implementou o caminho buffered sincronico antes de streaming. - Buffered deve ser entregue antes de streaming. - Streaming deve ter nomes explicitos com `Unbuffered`. +- Prompt 9.4 implementou `QueryMappedUnbuffered*` sincrono sem misturar com + async streaming. - `IAsyncEnumerable` deve ser avaliado como mudanca de API/dependencia para `netstandard2.0`. - Cancellation deve usar `CommandDefinition.CancellationToken` e overloads @@ -140,7 +178,7 @@ Nenhuma feature produtiva em andamento. ## APIs propostas -APIs conceituais a avaliar nos prompts de implementacao: +APIs implementadas e conceituais para prompts futuros: ```csharp using var multi = connection.QueryMultipleMapped(sql); @@ -163,15 +201,16 @@ await foreach (var customer in connection.QueryMappedUnbufferedAsync( } ``` -Nomes finais ainda dependem de revisao de overloads, target framework e -compatibilidade. +`QueryMappedUnbuffered*` sincrono foi implementado no Prompt 9.4. O nome async +ainda depende de revisao de overloads, target framework e compatibilidade. ## Riscos conhecidos - `GridReader` nao expoe reader publico suficiente para `ReadMapped`. - Implementar wrapper proprio exige lifetime correto de connection, command e reader. -- Streaming pode vazar recursos se enumeradores nao forem descartados. +- Streaming pode manter recursos abertos por mais tempo se enumeradores nao + forem descartados. - Cancellation depende do suporte real do provider. - `IAsyncEnumerable` em API publica `netstandard2.0` pode alterar dependencias/compatibilidade. @@ -185,6 +224,9 @@ compatibilidade. - `.sdd/etapa-9/01-historical-query-issues.md` - `.sdd/etapa-9/02-advanced-query-materialization-spec.md` - `.sdd/etapa-9/03-query-multiple-design.md` +- `.sdd/etapa-9/04-read-mapped-spec.md` +- `.sdd/etapa-9/05-unbuffered-materialization.md` +- `.sdd/etapa-9/06-performance-results.md` - `.sdd/etapa-9/DECISIONS.md` - `.sdd/etapa-9/STATUS.md` - `src/Dapper.FluentMap/MappedGridReader.cs` @@ -199,8 +241,9 @@ compatibilidade. - `test/Dapper.FluentMap.Tests/MappingProfileTests.cs` - `test/Dapper.FluentMap.Tests/GeneratedMaterializerContractTests.cs` - `test/Dapper.FluentMap.Tests/QueryMultipleMappedTests.cs` +- `test/Dapper.FluentMap.Tests/QueryMappedUnbufferedTests.cs` - `benchmarks/Dapper.FluentMap.Benchmarks/Program.cs` ## Ultimo prompt executado -Ultimo prompt executado: 9.3 +Ultimo prompt executado: 9.4 diff --git a/README.md b/README.md index ab7b205..59b69e6 100644 --- a/README.md +++ b/README.md @@ -433,6 +433,7 @@ Use FluentMap query helpers when you need FluentMap-controlled advanced material connection.QueryMapped(sql); connection.QueryMappedSingle(sql); connection.QueryMappedSingle(sql); +connection.QueryMappedUnbuffered(sql); using var multi = connection.QueryMultipleMapped(sql); var customers = multi.ReadMapped(); @@ -441,6 +442,17 @@ var orders = multi.ReadMapped(); `QueryMapped*` and `ReadMapped*` return buffered results and are the paths that support nested object materialization, constructor-built value objects and profile-specific mapping. +Use `QueryMappedUnbuffered()` or `QueryMappedUnbuffered()` when you need to process a large result set incrementally: + +```csharp +foreach (var customer in connection.QueryMappedUnbuffered(sql)) +{ + Process(customer); +} +``` + +Unbuffered queries are lazy: the command is executed when enumeration starts, not when the method is called. The underlying reader stays open until enumeration finishes or the enumerator is disposed. If FluentMap opens a closed connection for the enumeration, disposing the reader closes it again; if the connection was already open, it remains open and must stay usable for the whole enumeration. Dispose the enumerator, for example by using `foreach`, when stopping early. + ## Dommel Install `Dapper.FluentMap.Dommel` when using [Dommel](https://github.com/henkmollema/Dommel): @@ -515,7 +527,7 @@ persistence behavior that matches the intent: `ReadOnly()`, `Computed()`, - Assembly scanning depends on reflection discovery and is not the recommended path for trimmed or Native AOT applications. - `QueryMapped*` may use generated materializers for supported flat, nested and Value Object shapes, but it can still fall back to runtime metadata and dynamic code; it is not yet a guaranteed Native AOT-safe materialization path. - Mapping profiles are selected through `QueryMapped()` and `ReadMapped()` APIs. -- `QueryMapped*` and `ReadMapped*` are buffered; they do not expose unbuffered streaming. +- `QueryMapped*` and `ReadMapped*` are buffered. Use `QueryMappedUnbuffered*` for explicit synchronous unbuffered streaming. - Value object construction uses matching public constructors, not factory methods. ## Contributing @@ -971,6 +983,7 @@ Use os helpers de consulta do FluentMap quando precisar de materialização avan connection.QueryMapped(sql); connection.QueryMappedSingle(sql); connection.QueryMappedSingle(sql); +connection.QueryMappedUnbuffered(sql); using var multi = connection.QueryMultipleMapped(sql); var customers = multi.ReadMapped(); @@ -979,6 +992,17 @@ var orders = multi.ReadMapped(); `QueryMapped*` e `ReadMapped*` retornam resultados bufferizados e são os caminhos que suportam materialização de objetos aninhados, Value Objects construídos por construtor e mapeamento específico por profile. +Use `QueryMappedUnbuffered()` ou `QueryMappedUnbuffered()` quando precisar processar um result set grande de forma incremental: + +```csharp +foreach (var customer in connection.QueryMappedUnbuffered(sql)) +{ + Process(customer); +} +``` + +Consultas unbuffered são lazy: o comando é executado quando a enumeração começa, não quando o método é chamado. O reader subjacente permanece aberto até a enumeração terminar ou o enumerator ser descartado. Se o FluentMap abrir uma conexão fechada para a enumeração, o dispose do reader fecha a conexão novamente; se a conexão já estava aberta, ela permanece aberta e precisa continuar válida durante toda a enumeração. Descarte o enumerator, por exemplo usando `foreach`, ao parar cedo. + ## Dommel Instale `Dapper.FluentMap.Dommel` ao usar [Dommel](https://github.com/henkmollema/Dommel): @@ -1054,7 +1078,7 @@ ainda devem ser lidos, use o persistence behavior correspondente: - Assembly scanning depende de descoberta por reflection e não é o caminho recomendado para aplicações com trimming ou Native AOT. - `QueryMapped*` pode usar materializadores gerados para shapes flat, aninhados e Value Object suportados, mas ainda pode cair para metadados de runtime e código dinâmico; ele ainda não é um caminho de materialização garantidamente seguro para Native AOT. - Mapping profiles são selecionados pelas APIs `QueryMapped()` e `ReadMapped()`. -- `QueryMapped*` e `ReadMapped*` são bufferizados; eles não expõem streaming unbuffered. +- `QueryMapped*` e `ReadMapped*` são bufferizados. Use `QueryMappedUnbuffered*` para streaming unbuffered síncrono explícito. - A construção de Value Objects usa construtores públicos compatíveis, não factory methods. ## Contribuição diff --git a/benchmarks/Dapper.FluentMap.Benchmarks/Program.cs b/benchmarks/Dapper.FluentMap.Benchmarks/Program.cs index ce8648d..5aefd6d 100644 --- a/benchmarks/Dapper.FluentMap.Benchmarks/Program.cs +++ b/benchmarks/Dapper.FluentMap.Benchmarks/Program.cs @@ -44,9 +44,12 @@ public void GlobalSetup() _connection = OpenPopulatedConnection(); DapperPure(); + DapperPureUnbuffered(); DapperWithFluentMapRootMapping(); QueryMappedSimple(); + QueryMappedSimpleUnbuffered(); QueryMappedSimpleRuntimeFallback(); + QueryMappedSimpleUnbufferedRuntimeFallback(); QueryMappedImmutableConstructor(); QueryMappedNestedObject(); QueryMappedNestedObjectRuntimeFallback(); @@ -70,6 +73,15 @@ public int DapperPure() .Count; } + [Benchmark] + public int DapperPureUnbuffered() + { + return _connection.Query( + "SELECT Id, Name, Age, Balance, CreatedAt FROM BenchmarkRows;", + buffered: false) + .Count(); + } + [Benchmark] public int DapperWithFluentMapRootMapping() { @@ -87,6 +99,14 @@ public int QueryMappedSimple() .Count(); } + [Benchmark] + public int QueryMappedSimpleUnbuffered() + { + return _connection.QueryMappedUnbuffered( + "SELECT Id AS customer_id, Name AS full_name, Age AS customer_age, Balance AS account_balance, CreatedAt AS created_at FROM BenchmarkRows;") + .Count(); + } + [Benchmark] public int QueryMappedSimpleRuntimeFallback() { @@ -95,6 +115,14 @@ public int QueryMappedSimpleRuntimeFallback() .Count(); } + [Benchmark] + public int QueryMappedSimpleUnbufferedRuntimeFallback() + { + return _connection.QueryMappedUnbuffered( + "SELECT Name AS full_name, Id AS customer_id, Age AS customer_age, Balance AS account_balance, CreatedAt AS created_at FROM BenchmarkRows;") + .Count(); + } + [Benchmark] public int QueryMappedImmutableConstructor() { diff --git a/src/Dapper.FluentMap/Materialization/MappedRowMaterializer.cs b/src/Dapper.FluentMap/Materialization/MappedRowMaterializer.cs index 3d7ccf1..66d4a0f 100644 --- a/src/Dapper.FluentMap/Materialization/MappedRowMaterializer.cs +++ b/src/Dapper.FluentMap/Materialization/MappedRowMaterializer.cs @@ -14,8 +14,25 @@ internal static IEnumerable Materialize< Type profileType) where TEntity : class { - var columnNames = GetColumnNames(reader); var results = new List(); + var materializer = CreateMaterializer(reader, profileType); + + while (reader.Read()) + { + results.Add(materializer(reader)); + } + + return results; + } + + internal static Func CreateMaterializer< + [DynamicallyAccessedMembers(QueryMappedApiAnnotations.MaterializedEntityMemberTypes)] + TEntity>( + IDataRecord reader, + Type profileType) + where TEntity : class + { + var columnNames = GetColumnNames(reader); Func generatedMaterializer; if (FluentMapper.Registry.TryGetGeneratedMaterializer( @@ -24,22 +41,11 @@ internal static IEnumerable Materialize< columnNames, out generatedMaterializer)) { - while (reader.Read()) - { - results.Add((TEntity)generatedMaterializer(reader)); - } - - return results; + return record => (TEntity)generatedMaterializer(record); } var plan = FluentMapper.Registry.GetMaterializationPlan(typeof(TEntity), profileType, columnNames); - - while (reader.Read()) - { - results.Add((TEntity)plan.Materialize(reader)); - } - - return results; + return record => (TEntity)plan.Materialize(record); } private static string[] GetColumnNames(IDataRecord reader) diff --git a/src/Dapper.FluentMap/QueryMappedExtensions.cs b/src/Dapper.FluentMap/QueryMappedExtensions.cs index 8c99d29..78f28c3 100644 --- a/src/Dapper.FluentMap/QueryMappedExtensions.cs +++ b/src/Dapper.FluentMap/QueryMappedExtensions.cs @@ -131,6 +131,128 @@ public static IEnumerable QueryMapped< return ExecuteMapped(connection, command, typeof(TProfile)); } + /// + /// Creates a lazy unbuffered query that 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. + /// A lazy sequence that keeps the underlying reader open until enumeration completes or the enumerator is disposed. + [RequiresUnreferencedCode(QueryMappedApiAnnotations.RequiresUnreferencedCodeMessage)] + [RequiresDynamicCode(QueryMappedApiAnnotations.RequiresDynamicCodeMessage)] + public static IEnumerable QueryMappedUnbuffered< + [DynamicallyAccessedMembers(QueryMappedApiAnnotations.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)); + } + + return QueryMappedUnbuffered( + connection, + new CommandDefinition(sql, param, transaction, commandTimeout, commandType, CommandFlags.None)); + } + + /// + /// Creates a lazy unbuffered query that 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. + /// A lazy sequence that keeps the underlying reader open until enumeration completes or the enumerator is disposed. + [RequiresUnreferencedCode(QueryMappedApiAnnotations.RequiresUnreferencedCodeMessage)] + [RequiresDynamicCode(QueryMappedApiAnnotations.RequiresDynamicCodeMessage)] + public static IEnumerable QueryMappedUnbuffered< + [DynamicallyAccessedMembers(QueryMappedApiAnnotations.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 (connection == null) + { + throw new ArgumentNullException(nameof(connection)); + } + + if (sql == null) + { + throw new ArgumentNullException(nameof(sql)); + } + + return QueryMappedUnbuffered( + connection, + new CommandDefinition(sql, param, transaction, commandTimeout, commandType, CommandFlags.None)); + } + + /// + /// Creates a lazy unbuffered command that materializes rows using FluentMap's opt-in nested object materializer. + /// + /// The entity type to materialize. + /// The database connection. + /// The command to execute. + /// A lazy sequence that keeps the underlying reader open until enumeration completes or the enumerator is disposed. + [RequiresUnreferencedCode(QueryMappedApiAnnotations.RequiresUnreferencedCodeMessage)] + [RequiresDynamicCode(QueryMappedApiAnnotations.RequiresDynamicCodeMessage)] + public static IEnumerable QueryMappedUnbuffered< + [DynamicallyAccessedMembers(QueryMappedApiAnnotations.MaterializedEntityMemberTypes)] + TEntity>( + this IDbConnection connection, + CommandDefinition command) + where TEntity : class + { + return ExecuteMappedUnbuffered(connection, command, profileType: null); + } + + /// + /// Creates a lazy unbuffered command that 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. + /// A lazy sequence that keeps the underlying reader open until enumeration completes or the enumerator is disposed. + [RequiresUnreferencedCode(QueryMappedApiAnnotations.RequiresUnreferencedCodeMessage)] + [RequiresDynamicCode(QueryMappedApiAnnotations.RequiresDynamicCodeMessage)] + public static IEnumerable QueryMappedUnbuffered< + [DynamicallyAccessedMembers(QueryMappedApiAnnotations.MaterializedEntityMemberTypes)] + TEntity, + TProfile>( + this IDbConnection connection, + CommandDefinition command) + where TEntity : class + where TProfile : IMappingProfile + { + return ExecuteMappedUnbuffered(connection, command, typeof(TProfile)); + } + /// /// Executes a query and materializes exactly one row using FluentMap's opt-in nested object materializer. /// @@ -354,6 +476,41 @@ private static IEnumerable ExecuteMapped< } } + private static IEnumerable ExecuteMappedUnbuffered< + [DynamicallyAccessedMembers(QueryMappedApiAnnotations.MaterializedEntityMemberTypes)] + TEntity>( + IDbConnection connection, + CommandDefinition command, + Type profileType) + where TEntity : class + { + if (connection == null) + { + throw new ArgumentNullException(nameof(connection)); + } + + return ExecuteMappedUnbufferedIterator(connection, command, profileType); + } + + private static IEnumerable ExecuteMappedUnbufferedIterator< + [DynamicallyAccessedMembers(QueryMappedApiAnnotations.MaterializedEntityMemberTypes)] + TEntity>( + IDbConnection connection, + CommandDefinition command, + Type profileType) + where TEntity : class + { + using (var reader = SqlMapper.ExecuteReader(connection, command)) + { + var materializer = MappedRowMaterializer.CreateMaterializer(reader, profileType); + + while (reader.Read()) + { + yield return materializer(reader); + } + } + } + private static async Task> ExecuteMappedAsync< [DynamicallyAccessedMembers(QueryMappedApiAnnotations.MaterializedEntityMemberTypes)] TEntity>( diff --git a/test/Dapper.FluentMap.Tests/QueryMappedUnbufferedTests.cs b/test/Dapper.FluentMap.Tests/QueryMappedUnbufferedTests.cs new file mode 100644 index 0000000..f73fd02 --- /dev/null +++ b/test/Dapper.FluentMap.Tests/QueryMappedUnbufferedTests.cs @@ -0,0 +1,589 @@ +using System; +using System.Data; +using System.IO; +using System.Linq; +using Dapper; +using Dapper.FluentMap.Mapping; +using Dapper.FluentMap.Materialization; +using Microsoft.Data.Sqlite; +using Xunit; + +namespace Dapper.FluentMap.Tests +{ + public class QueryMappedUnbufferedTests + { + [Fact] + [Trait("Category", "Integration")] + public void QueryMappedUnbufferedShouldMaterializeFlatEntity() + { + PreTest(typeof(FlatCustomer)); + + try + { + FluentMapper.Initialize(configuration => configuration.AddMap(new FlatCustomerMap())); + + using (var connection = new SqliteConnection("Data Source=:memory:")) + { + var customers = connection.QueryMappedUnbuffered( + "SELECT 1 AS customer_id, 'Ada' AS customer_name UNION ALL SELECT 2, 'Grace';") + .ToList(); + + Assert.Collection( + customers, + first => + { + Assert.Equal(1, first.Id); + Assert.Equal("Ada", first.Name); + }, + second => + { + Assert.Equal(2, second.Id); + Assert.Equal("Grace", second.Name); + }); + Assert.Equal(ConnectionState.Closed, connection.State); + } + } + finally + { + PreTest(typeof(FlatCustomer)); + } + } + + [Fact] + [Trait("Category", "Integration")] + public void QueryMappedUnbufferedShouldMaterializeNestedObjectsAndValueObjects() + { + PreTest(typeof(ComplexCustomer)); + + try + { + FluentMapper.Initialize(configuration => configuration.AddMap(new ComplexCustomerMap())); + + using (var connection = new SqliteConnection("Data Source=:memory:")) + { + var customer = connection.QueryMappedUnbuffered( + "SELECT 13 AS customer_id, 'Sao Paulo' AS city, 'ada@example.com' AS email;") + .Single(); + + Assert.Equal(13, customer.Id); + Assert.NotNull(customer.Address); + Assert.Equal("Sao Paulo", customer.Address.City); + Assert.Equal(new ComplexEmail("ada@example.com"), customer.Email); + Assert.Equal(ConnectionState.Closed, connection.State); + } + } + finally + { + PreTest(typeof(ComplexCustomer)); + } + } + + [Fact] + [Trait("Category", "Integration")] + public void QueryMappedUnbufferedShouldUseProfile() + { + PreTest(typeof(FlatCustomer)); + + try + { + FluentMapper.Initialize(configuration => configuration.AddProfile()); + + using (var connection = new SqliteConnection("Data Source=:memory:")) + { + var customer = connection.QueryMappedUnbuffered( + "SELECT 7 AS legacy_id, 'Legacy Ltd.' AS legal_name;") + .Single(); + + Assert.Equal(7, customer.Id); + Assert.Equal("Legacy Ltd.", customer.Name); + Assert.Equal(ConnectionState.Closed, connection.State); + } + } + finally + { + PreTest(typeof(FlatCustomer)); + } + } + + [Fact] + [Trait("Category", "Integration")] + public void QueryMappedUnbufferedShouldUseGeneratedMaterializerWhenRegistered() + { + PreTest(typeof(FlatCustomer)); + + try + { + FluentMapper.Initialize(configuration => + { + configuration.AddMap(new FlatCustomerMap()); + configuration.AddGeneratedMaterializer( + new[] + { + GeneratedMaterializerColumn.Map("customer_id", nameof(FlatCustomer.Id)), + GeneratedMaterializerColumn.Map("customer_name", nameof(FlatCustomer.Name)) + }, + record => new FlatCustomer + { + Id = Convert.ToInt32(record.GetValue(0)), + Name = "generated:" + Convert.ToString(record.GetValue(1)) + }); + }); + + using (var connection = new SqliteConnection("Data Source=:memory:")) + { + var customer = connection.QueryMappedUnbuffered( + "SELECT 3 AS customer_id, 'Ada' AS customer_name;") + .Single(); + + Assert.Equal(3, customer.Id); + Assert.Equal("generated:Ada", customer.Name); + Assert.Equal(0, FluentMapper.Registry.MaterializationPlanCacheEntryCount); + Assert.Equal(ConnectionState.Closed, connection.State); + } + } + finally + { + PreTest(typeof(FlatCustomer)); + } + } + + [Fact] + [Trait("Category", "Integration")] + public void QueryMappedUnbufferedShouldUseRuntimeFallbackWhenNoGeneratedMaterializerMatches() + { + PreTest(typeof(FlatCustomer)); + + try + { + FluentMapper.Initialize(configuration => configuration.AddMap(new FlatCustomerMap())); + + using (var connection = new SqliteConnection("Data Source=:memory:")) + { + var customer = connection.QueryMappedUnbuffered( + "SELECT 'Ada' AS customer_name, 1 AS customer_id;") + .Single(); + + Assert.Equal(1, customer.Id); + Assert.Equal("Ada", customer.Name); + Assert.Equal(1, FluentMapper.Registry.MaterializationPlanCacheEntryCount); + Assert.Equal(ConnectionState.Closed, connection.State); + } + } + finally + { + PreTest(typeof(FlatCustomer)); + } + } + + [Fact] + [Trait("Category", "Integration")] + public void QueryMappedUnbufferedShouldNotExecuteUntilEnumerated() + { + PreTest(typeof(FlatCustomer)); + + try + { + FluentMapper.Initialize(configuration => configuration.AddMap(new FlatCustomerMap())); + + using (var connection = new SqliteConnection("Data Source=:memory:")) + { + var customers = connection.QueryMappedUnbuffered( + "SELECT 1 AS customer_id, 'Ada' AS customer_name;"); + + Assert.Equal(ConnectionState.Closed, connection.State); + + using (customers.GetEnumerator()) + { + Assert.Equal(ConnectionState.Closed, connection.State); + } + } + } + finally + { + PreTest(typeof(FlatCustomer)); + } + } + + [Fact] + [Trait("Category", "Integration")] + public void QueryMappedUnbufferedShouldCloseConnectionItOpenedAfterCompleteEnumeration() + { + PreTest(typeof(FlatCustomer)); + + try + { + FluentMapper.Initialize(configuration => configuration.AddMap(new FlatCustomerMap())); + + using (var connection = new SqliteConnection("Data Source=:memory:")) + { + var customers = connection.QueryMappedUnbuffered( + "SELECT 1 AS customer_id, 'Ada' AS customer_name UNION ALL SELECT 2, 'Grace';") + .ToList(); + + Assert.Equal(2, customers.Count); + Assert.Equal(ConnectionState.Closed, connection.State); + } + } + finally + { + PreTest(typeof(FlatCustomer)); + } + } + + [Fact] + [Trait("Category", "Integration")] + public void QueryMappedUnbufferedShouldCloseConnectionItOpenedAfterEarlyBreak() + { + PreTest(typeof(FlatCustomer)); + + try + { + FluentMapper.Initialize(configuration => configuration.AddMap(new FlatCustomerMap())); + + using (var connection = new SqliteConnection("Data Source=:memory:")) + { + var seen = 0; + + foreach (var customer in connection.QueryMappedUnbuffered( + "SELECT 1 AS customer_id, 'Ada' AS customer_name UNION ALL SELECT 2, 'Grace';")) + { + Assert.Equal(1, customer.Id); + seen++; + break; + } + + Assert.Equal(1, seen); + Assert.Equal(ConnectionState.Closed, connection.State); + } + } + finally + { + PreTest(typeof(FlatCustomer)); + } + } + + [Fact] + [Trait("Category", "Integration")] + public void QueryMappedUnbufferedShouldCloseConnectionItOpenedWhenEnumeratorIsDisposed() + { + PreTest(typeof(FlatCustomer)); + + try + { + FluentMapper.Initialize(configuration => configuration.AddMap(new FlatCustomerMap())); + + using (var connection = new SqliteConnection("Data Source=:memory:")) + { + var enumerator = connection.QueryMappedUnbuffered( + "SELECT 1 AS customer_id, 'Ada' AS customer_name UNION ALL SELECT 2, 'Grace';") + .GetEnumerator(); + + Assert.True(enumerator.MoveNext()); + Assert.Equal(ConnectionState.Open, connection.State); + + enumerator.Dispose(); + + Assert.Equal(ConnectionState.Closed, connection.State); + } + } + finally + { + PreTest(typeof(FlatCustomer)); + } + } + + [Fact] + [Trait("Category", "Integration")] + public void QueryMappedUnbufferedShouldKeepOpenConnectionOpenAfterEarlyBreak() + { + PreTest(typeof(FlatCustomer)); + + try + { + FluentMapper.Initialize(configuration => configuration.AddMap(new FlatCustomerMap())); + + using (var connection = OpenConnection()) + { + foreach (var customer in connection.QueryMappedUnbuffered( + "SELECT 1 AS customer_id, 'Ada' AS customer_name UNION ALL SELECT 2, 'Grace';")) + { + Assert.Equal(1, customer.Id); + break; + } + + Assert.Equal(ConnectionState.Open, connection.State); + } + } + finally + { + PreTest(typeof(FlatCustomer)); + } + } + + [Fact] + [Trait("Category", "Integration")] + public void QueryMappedUnbufferedShouldPropagateTransaction() + { + PreTest(typeof(FlatCustomer)); + + try + { + FluentMapper.Initialize(configuration => configuration.AddMap(new FlatCustomerMap())); + + using (var connection = OpenConnection()) + { + connection.Execute("CREATE TABLE customers (customer_id INTEGER NOT NULL, customer_name TEXT NOT NULL);"); + + using (var transaction = connection.BeginTransaction()) + { + connection.Execute( + "INSERT INTO customers (customer_id, customer_name) VALUES (9, 'Transaction');", + transaction: transaction); + + var customer = connection.QueryMappedUnbuffered( + "SELECT customer_id, customer_name FROM customers WHERE customer_id = @id;", + new { id = 9 }, + transaction) + .Single(); + + Assert.Equal(9, customer.Id); + Assert.Equal("Transaction", customer.Name); + Assert.Equal(ConnectionState.Open, connection.State); + + transaction.Rollback(); + } + } + } + finally + { + PreTest(typeof(FlatCustomer)); + } + } + + [Fact] + [Trait("Category", "Integration")] + public void QueryMappedUnbufferedShouldDisposeReaderWhenMaterializationThrowsMidEnumeration() + { + var databasePath = Path.Combine(Path.GetTempPath(), "DapperFluentMap-" + Guid.NewGuid().ToString("N") + ".db"); + + PreTest(typeof(ThrowingCustomer)); + + try + { + FluentMapper.Initialize(configuration => configuration.AddMap(new ThrowingCustomerMap())); + + using (var setup = new SqliteConnection("Data Source=" + databasePath)) + { + setup.Open(); + setup.Execute("CREATE TABLE customers (customer_id INTEGER NOT NULL, cpf TEXT NOT NULL);"); + setup.Execute("INSERT INTO customers (customer_id, cpf) VALUES (1, '12345678909'), (2, ''), (3, '98765432100');"); + } + + using (var connection = new SqliteConnection("Data Source=" + databasePath)) + using (var enumerator = connection.QueryMappedUnbuffered( + "SELECT customer_id, cpf FROM customers ORDER BY customer_id;").GetEnumerator()) + { + Assert.True(enumerator.MoveNext()); + Assert.Equal(1, enumerator.Current.Id); + Assert.Equal(ConnectionState.Open, connection.State); + + var exception = Assert.Throws(() => enumerator.MoveNext()); + + Assert.IsType(exception.InnerException); + Assert.Equal(ConnectionState.Closed, connection.State); + } + } + finally + { + PreTest(typeof(ThrowingCustomer)); + SqliteConnection.ClearAllPools(); + + if (File.Exists(databasePath)) + { + File.Delete(databasePath); + } + } + } + + [Fact] + [Trait("Category", "Integration")] + public void QueryMappedUnbufferedShouldReturnEmptySequence() + { + PreTest(typeof(FlatCustomer)); + + try + { + FluentMapper.Initialize(configuration => configuration.AddMap(new FlatCustomerMap())); + + using (var connection = new SqliteConnection("Data Source=:memory:")) + { + var customers = connection.QueryMappedUnbuffered( + "SELECT 1 AS customer_id, 'Ada' AS customer_name WHERE 1 = 0;") + .ToList(); + + Assert.Empty(customers); + Assert.Equal(ConnectionState.Closed, connection.State); + } + } + finally + { + PreTest(typeof(FlatCustomer)); + } + } + + [Fact] + [Trait("Category", "Integration")] + public void QueryMappedUnbufferedShouldProcessLargeSequenceWithOneMaterializerLookup() + { + PreTest(typeof(FlatCustomer)); + + try + { + FluentMapper.Initialize(configuration => configuration.AddMap(new FlatCustomerMap())); + + using (var connection = new SqliteConnection("Data Source=:memory:")) + { + var count = 0; + var lastId = 0; + + foreach (var customer in connection.QueryMappedUnbuffered( + @"WITH RECURSIVE numbers(Value) AS ( + SELECT 1 + UNION ALL + SELECT Value + 1 FROM numbers WHERE Value < 5000 + ) + SELECT Value AS customer_id, 'Customer ' || Value AS customer_name FROM numbers;")) + { + count++; + lastId = customer.Id; + } + + Assert.Equal(5000, count); + Assert.Equal(5000, lastId); + Assert.Equal(1, FluentMapper.Registry.MaterializationPlanCacheEntryCount); + Assert.Equal(ConnectionState.Closed, connection.State); + } + } + finally + { + PreTest(typeof(FlatCustomer)); + } + } + + 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 FlatCustomer + { + public int Id { get; set; } + + public string Name { get; set; } + } + + private sealed class FlatCustomerMap : EntityMap + { + public FlatCustomerMap() + { + Map(customer => customer.Id).ToColumn("customer_id"); + Map(customer => customer.Name).ToColumn("customer_name"); + } + } + + private sealed class LegacyCustomerMap : EntityMap, IProfileMap + { + public LegacyCustomerMap() + { + Map(customer => customer.Id).ToColumn("legacy_id"); + Map(customer => customer.Name).ToColumn("legal_name"); + } + } + + private sealed class ComplexCustomer + { + public ComplexCustomer(int id, ComplexAddress address, ComplexEmail email) + { + Id = id; + Address = address; + Email = email; + } + + public int Id { get; } + + public ComplexAddress Address { get; } + + public ComplexEmail Email { get; } + } + + private sealed class ComplexAddress + { + public ComplexAddress(string city) + { + City = city; + } + + public string City { get; } + } + + private sealed record ComplexEmail(string Value); + + private sealed class ComplexCustomerMap : EntityMap + { + public ComplexCustomerMap() + { + Map(customer => customer.Id).ToColumn("customer_id"); + Map(customer => customer.Address.City).ToColumn("city"); + Map(customer => customer.Email.Value).ToColumn("email"); + } + } + + private sealed class ThrowingCustomer + { + public ThrowingCustomer(int id, ThrowingCpf cpf) + { + Id = id; + Cpf = cpf; + } + + public int Id { get; } + + public ThrowingCpf Cpf { get; } + } + + private sealed class ThrowingCustomerMap : EntityMap + { + public ThrowingCustomerMap() + { + Map(customer => customer.Id).ToColumn("customer_id"); + Map(customer => customer.Cpf.Number).ToColumn("cpf"); + } + } + + private sealed class ThrowingCpf + { + public ThrowingCpf(string number) + { + if (string.IsNullOrWhiteSpace(number)) + { + throw new ArgumentException("CPF cannot be empty.", nameof(number)); + } + + Number = number; + } + + public string Number { get; } + } + } +} From 3a70146f7133436e7583518419f9f69c3468f0c1 Mon Sep 17 00:00:00 2001 From: Rodrigo de Oliveira Date: Tue, 28 Jul 2026 15:48:10 -0300 Subject: [PATCH 19/49] feat(query): add async mapped streaming --- .sdd/etapa-9/06-performance-results.md | 60 ++ .sdd/etapa-9/07-async-streaming-spec.md | 234 +++++++ .sdd/etapa-9/DECISIONS.md | 44 ++ .sdd/etapa-9/STATUS.md | 74 +- README.md | 32 +- .../Dapper.FluentMap.Benchmarks.csproj | 1 + .../Dapper.FluentMap.Benchmarks/Program.cs | 39 +- src/Dapper.FluentMap/Dapper.FluentMap.csproj | 2 + src/Dapper.FluentMap/QueryMappedExtensions.cs | 266 +++++++ .../Dapper.FluentMap.Analyzers.Tests.csproj | 1 + .../Dapper.FluentMap.Dommel.Tests.csproj | 1 + ...uentMap.GeneratedRegistration.Tests.csproj | 1 + .../Dapper.FluentMap.Generators.Tests.csproj | 1 + .../QueryMappedUnbufferedAsyncTests.cs | 659 ++++++++++++++++++ 14 files changed, 1399 insertions(+), 16 deletions(-) create mode 100644 .sdd/etapa-9/07-async-streaming-spec.md create mode 100644 test/Dapper.FluentMap.Tests/QueryMappedUnbufferedAsyncTests.cs diff --git a/.sdd/etapa-9/06-performance-results.md b/.sdd/etapa-9/06-performance-results.md index a5b50cc..bd8bc97 100644 --- a/.sdd/etapa-9/06-performance-results.md +++ b/.sdd/etapa-9/06-performance-results.md @@ -86,3 +86,63 @@ Ambiente reportado pelo BenchmarkDotNet: - Nested e Value Object unbuffered nao foram adicionados como metodos separados nesta rodada para evitar crescimento excessivo da matriz; o mecanismo e o mesmo, e a cobertura funcional exercita esses shapes. + +## Benchmarks adicionados no Prompt 9.5 + +O benchmark steady state passou a incluir: + +- Dapper `QueryUnbufferedAsync`; +- FluentMap `QueryMappedUnbufferedAsync` generated; +- FluentMap `QueryMappedUnbufferedAsync` runtime fallback. + +Esses cenarios medem overhead e alocacao por item no streaming assincrono. Eles +nao sao tratados como substitutos diretos dos cenarios sincronos porque o valor +de async depende do provider e de I/O real; SQLite em memoria pode completar de +forma essencialmente local. + +Comando executado: + +```bash +dotnet run --project benchmarks\Dapper.FluentMap.Benchmarks\Dapper.FluentMap.Benchmarks.csproj --configuration Release --no-build -- --filter *MaterializationSteadyStateBenchmarks* +``` + +Ambiente reportado pelo BenchmarkDotNet: + +- Windows 11 `10.0.26200.8875/25H2/2025Update/HudsonValley2`; +- CPU: 11th Gen Intel Core i5-1145G7; +- .NET SDK: `10.0.302`; +- Runtime: `.NET 10.0.10`; +- BenchmarkDotNet: `0.15.8`; +- Job: `ShortRun`, 1000 linhas por operacao. + +| Method | Mean | StdDev | Gen0 | Gen1 | Allocated | Alloc Ratio | +| --- | ---: | ---: | ---: | ---: | ---: | ---: | +| QueryMappedValueObject | 1.824 ms | 0.1105 ms | 62.5000 | 19.5313 | 276.5 KB | 0.98 | +| DapperPureUnbufferedAsync | 1.857 ms | 0.2207 ms | 62.5000 | - | 267.27 KB | 0.94 | +| QueryMappedValueObjectRuntimeFallback | 1.872 ms | 0.1797 ms | 136.7188 | 31.2500 | 587.99 KB | 2.08 | +| DapperPureUnbuffered | 1.953 ms | 0.1212 ms | 64.4531 | - | 266.96 KB | 0.94 | +| QueryMappedSimpleRuntimeFallback | 2.004 ms | 0.1523 ms | 85.9375 | 19.5313 | 361.58 KB | 1.28 | +| QueryMappedSimpleUnbufferedAsync | 2.013 ms | 0.2180 ms | 58.5938 | - | 245.59 KB | 0.87 | +| DapperPure | 2.168 ms | 0.1259 ms | 66.4063 | - | 283.17 KB | 1.00 | +| QueryMappedSimpleUnbufferedAsyncRuntimeFallback | 2.170 ms | 0.3495 ms | 82.0313 | - | 345.89 KB | 1.22 | +| DapperWithFluentMapRootMapping | 2.215 ms | 0.1494 ms | 62.5000 | - | 283.3 KB | 1.00 | +| QueryMappedImmutableConstructor | 2.319 ms | 0.1303 ms | 62.5000 | 11.7188 | 261.09 KB | 0.92 | +| QueryMappedNestedObject | 2.329 ms | 0.0504 ms | 70.3125 | 23.4375 | 292.47 KB | 1.03 | +| QueryMappedSimpleUnbufferedRuntimeFallback | 2.346 ms | 0.4538 ms | 83.9844 | - | 345.48 KB | 1.22 | +| QueryMappedSimpleUnbuffered | 2.434 ms | 0.1912 ms | 58.5938 | - | 245.17 KB | 0.87 | +| QueryMappedSimple | 2.873 ms | 0.5289 ms | 62.5000 | 11.7188 | 261.15 KB | 0.92 | +| QueryMappedNestedObjectRuntimeFallback | 3.010 ms | 0.1693 ms | 89.8438 | 27.3438 | 377.16 KB | 1.33 | + +## Leitura do Prompt 9.5 + +- O tempo local continuou ruidoso no `ShortRun`; as margens de erro sao grandes + demais para conclusoes de throughput. +- As alocacoes do FluentMap async streaming ficaram alinhadas ao unbuffered + sincrono: + - generated simple: `245.59 KB` async vs `245.17 KB` sincrono; + - runtime fallback simple: `345.89 KB` async vs `345.48 KB` sincrono. +- Dapper async unbuffered ficou em `267.27 KB`, proximo do Dapper unbuffered + sincrono em `266.96 KB`. +- A diferenca esperada e pequena porque o custo dominante permanece em rows, + provider/reader, column shape e objetos materializados; o async enumerator + adiciona pouco no cenario medido. diff --git a/.sdd/etapa-9/07-async-streaming-spec.md b/.sdd/etapa-9/07-async-streaming-spec.md new file mode 100644 index 0000000..f88e685 --- /dev/null +++ b/.sdd/etapa-9/07-async-streaming-spec.md @@ -0,0 +1,234 @@ +# Async Streaming Materialization + +Prompt executado em 2026-07-28. + +## API + +APIs publicas adicionadas ao core: + +```csharp +IAsyncEnumerable QueryMappedUnbufferedAsync( + this DbConnection connection, + string sql, + CancellationToken cancellationToken) + where TEntity : class; + +IAsyncEnumerable QueryMappedUnbufferedAsync( + this DbConnection connection, + string sql, + object param = null, + IDbTransaction transaction = null, + int? commandTimeout = null, + CommandType? commandType = null, + CancellationToken cancellationToken = default) + where TEntity : class; + +IAsyncEnumerable QueryMappedUnbufferedAsync( + this DbConnection connection, + string sql, + CancellationToken cancellationToken) + where TEntity : class + where TProfile : IMappingProfile; + +IAsyncEnumerable QueryMappedUnbufferedAsync( + this DbConnection connection, + string sql, + object param = null, + IDbTransaction transaction = null, + int? commandTimeout = null, + CommandType? commandType = null, + CancellationToken cancellationToken = default) + where TEntity : class + where TProfile : IMappingProfile; + +IAsyncEnumerable QueryMappedUnbufferedAsync( + this DbConnection connection, + CommandDefinition command) + where TEntity : class; + +IAsyncEnumerable QueryMappedUnbufferedAsync( + this DbConnection connection, + CommandDefinition command) + where TEntity : class + where TProfile : IMappingProfile; +``` + +O receiver e `DbConnection`, nao `IDbConnection`, porque o contrato real de +streaming assincrono precisa de `DbDataReader.ReadAsync(...)`. O caminho +sincrono `QueryMappedUnbuffered*` permanece em `IDbConnection`. + +O pacote principal continua em `netstandard2.0`, mas agora fixa +`LangVersion` em `8.0` e declara dependencia direta de +`Microsoft.Bcl.AsyncInterfaces` porque `IAsyncEnumerable` passou a fazer +parte da superficie publica. + +## IAsyncEnumerable + +`QueryMappedUnbufferedAsync*` retorna uma sequencia lazy. O comando nao e +executado quando o metodo publico e chamado nem quando `GetAsyncEnumerator()` e +obtido. A execucao ocorre no primeiro `MoveNextAsync()`. + +Cada nova enumeracao cria um novo reader e executa o comando novamente. + +## Cancellation + +Cancellation e propagada por tres pontos: + +- o `CancellationToken` dos overloads convenientes e gravado no + `CommandDefinition`; +- o token efetivo do async enumerator e aplicado ao `CommandDefinition` antes + de chamar `SqlMapper.ExecuteReaderAsync`; +- o loop chama `ThrowIfCancellationRequested()` e passa o token a + `DbDataReader.ReadAsync(cancellationToken)`. + +O parametro interno do async iterator usa `[EnumeratorCancellation]`. Assim, +quando o usuario combina um token no metodo e outro via `await foreach` / +`WithCancellation`, o compilador pode produzir o token efetivo esperado para a +enumeracao. + +`OperationCanceledException` nao e capturada nem convertida em erro de +configuracao. + +Testes cobrem: + +- cancellation antes da execucao; +- cancellation durante a enumeracao; +- cancellation apos enumeracao parcial, seguida de dispose do enumerator. + +## Reader lifetime + +O `DbDataReader` fica aberto durante a enumeracao assincrona e e descartado em +`finally` quando: + +- o result set termina; +- o consumidor para cedo; +- o enumerator e descartado explicitamente; +- cancellation interrompe o loop; +- materializacao ou leitura falha. + +Quando o reader implementa `IAsyncDisposable`, o FluentMap chama +`DisposeAsync()`. Caso contrario, chama `Dispose()` como fallback compativel com +`netstandard2.0`. + +## Connection lifetime + +O FluentMap nao assume ownership da conexao recebida. + +Regra preservada: + +- conexao inicialmente fechada: Dapper/provider abre no primeiro + `MoveNextAsync()` e o dispose do reader fecha ao final, early break, + cancellation, dispose explicito ou excecao; +- conexao inicialmente aberta: permanece aberta depois da enumeracao ou dispose + do enumerator. + +O usuario precisa manter a conexao e a transacao externas validas durante toda +a enumeracao. + +## Command lifetime + +O comando e criado e gerenciado pelo caminho publico do Dapper usado por +`SqlMapper.ExecuteReaderAsync`. O FluentMap e dono do reader retornado e fecha o +reader para liberar command e recursos auxiliares do provider. + +Os overloads por `CommandDefinition` preservam command text, parametros, +transacao, timeout, command type e flags. O token efetivo da enumeracao e +copiado para um novo `CommandDefinition` antes da execucao, para que o provider +receba o token correto. + +## Async disposal + +Nao ha `.Result`, `.Wait()` ou bloqueio equivalente no caminho produtivo. + +O dispose do reader ocorre por `await DisposeReaderAsync(reader)`. A rotina usa +`IAsyncDisposable.DisposeAsync()` quando tecnicamente disponivel e recai para +`Dispose()` apenas para readers que nao expoem async disposal. + +## Exception semantics + +- `connection == null` falha com `ArgumentNullException` na chamada publica. +- `sql == null` falha com `ArgumentNullException` na chamada publica. +- Profile ausente preserva `FluentMapConfigurationException`. +- Excecoes de dominio/construtor seguem o comportamento atual do materializer + runtime, incluindo wrapping em `FluentMapConfigurationException` quando + aplicavel. +- Excecoes de ADO.NET/Dapper durante execute/read/dispose nao sao convertidas + para excecoes de configuracao. +- Cancellation propaga `OperationCanceledException`. + +Em todos os casos apos a abertura do reader, o `finally` descarta o reader antes +de a excecao sair para o consumidor. + +## Generated materializer + +Generated materializers continuam sendo sincronos por linha. A operacao async +fica concentrada em I/O: + +```text +ExecuteReaderAsync + -> capturar shape de colunas + -> resolver materializer gerado ou runtime uma vez + -> ReadAsync por linha + -> materializer(record) sincrono +``` + +Nao foi criado contrato de materializer async porque a leitura do valor ja +ocorreu quando o delegate e chamado. + +## Runtime materializer + +O fallback runtime usa o mesmo `NestedMaterializationPlan` dos caminhos +buffered e unbuffered sincrono. O plano e resolvido uma vez por enumeracao, +depois que o reader esta aberto e o shape ordenado de colunas e conhecido. + +A cache existente continua por: + +```text +EntityType + ProfileType opcional + ordered ColumnShape +``` + +## Profiles + +Profiles sao suportados pelas variantes: + +```csharp +connection.QueryMappedUnbufferedAsync(sql, cancellationToken); +``` + +Isso preserva a semantica dos caminhos buffered e unbuffered sincrono: +selecionar profile por operacao nao altera type maps globais do Dapper e usa +cache/materializers separados do mapping default. + +## Providers + +O contrato produtivo depende de: + +- `DbConnection`; +- `DbDataReader`; +- `IDbTransaction`; +- `CommandDefinition`; +- contratos publicos de Dapper. + +Nao ha dependencia de SQLite, SQL Server, stored procedures ou parsing de SQL. +Os testes usam SQLite porque ele fornece provider real para connection lifetime, +async reader e transacao em memoria. + +Providers podem implementar async de forma internamente sincronica. O FluentMap +nao tenta mascarar isso; ele apenas usa os contratos async disponiveis e +propaga cancellation aos pontos que aceitam token. + +## Performance expectations + +O caminho async streaming evita a `List` do buffered e materializa uma +linha por `ReadAsync`. Ainda ha alocacoes esperadas para: + +- async enumerator/state machine; +- reader/command do provider; +- shape de colunas por enumeracao; +- delegate/wrapper de materializer por enumeracao; +- entidades, nested objects e Value Objects; +- possivel linked cancellation token quando multiplos tokens sao combinados. + +Benchmarks foram adicionados para steady state async unbuffered, focando em +overhead e alocacao por item. Resultados locais ficam em +`.sdd/etapa-9/06-performance-results.md`. diff --git a/.sdd/etapa-9/DECISIONS.md b/.sdd/etapa-9/DECISIONS.md index c4d62cd..a8a1887 100644 --- a/.sdd/etapa-9/DECISIONS.md +++ b/.sdd/etapa-9/DECISIONS.md @@ -333,3 +333,47 @@ excecao interrompe o loop. O usuario ganha processamento incremental sincrono sem async streaming. O contrato exige que conexao/transacao externas permanecam validas durante a enumeracao. Async streaming permanece para prompt futuro. + +## ADR-13 - QueryMappedUnbufferedAsync + +### Contexto + +O Prompt 9.5 precisava entregar streaming assincrono real, sem materializar uma +lista via `QueryAsync`. A API publica do pacote core permanece em +`netstandard2.0`, enquanto `IAsyncEnumerable` e async streams exigem suporte +de compilador e assemblies auxiliares nesse target. + +### Decisao + +Adicionar `QueryMappedUnbufferedAsync` e +`QueryMappedUnbufferedAsync` como APIs lazy baseadas em +`DbConnection`, retornando `IAsyncEnumerable`. + +O receiver e `DbConnection`, nao `IDbConnection`, porque a implementacao precisa +de `DbDataReader.ReadAsync(CancellationToken)` para streaming async real. + +Fixar `LangVersion` do core em `8.0` e declarar dependencia direta de +`Microsoft.Bcl.AsyncInterfaces` 10.0.8, pois `IAsyncEnumerable` entrou na +superficie publica `netstandard2.0`. + +### Alternativas consideradas + +- Usar `QueryAsync` e converter para `IAsyncEnumerable`: descartado porque seria + falso streaming. +- Usar `IDbConnection` e o overload async do Dapper que retorna `IDataReader`: + descartado porque `IDataReader` nao expoe `ReadAsync`. +- Criar materializers async: descartado porque a leitura de I/O ja ocorreu + antes da materializacao da linha. + +### Consequencias + +O caminho async streaming executa `SqlMapper.ExecuteReaderAsync`, resolve o +materializer uma vez por shape, chama `ReadAsync` por linha e materializa a +linha de forma sincrona. + +Cancellation e parte do contrato: o token e aplicado ao `CommandDefinition`, +ao `ReadAsync` e ao loop do async iterator com `[EnumeratorCancellation]`. + +Projetos de teste/benchmark que tambem traziam `Microsoft.Bcl.AsyncInterfaces` +6.0 por dependencias de tooling passaram a fixar 10.0.8 explicitamente para +evitar conflitos MSBuild de assembly. diff --git a/.sdd/etapa-9/STATUS.md b/.sdd/etapa-9/STATUS.md index b413f38..9fba7e7 100644 --- a/.sdd/etapa-9/STATUS.md +++ b/.sdd/etapa-9/STATUS.md @@ -82,6 +82,29 @@ equivalencia entre materializacao generated e runtime. unbuffered generated/runtime fallback. - Prompt 9.4: criada `.sdd/etapa-9/06-performance-results.md` para baseline e resultados. +- Prompt 9.5: criada especificacao + `.sdd/etapa-9/07-async-streaming-spec.md`. +- Prompt 9.5: adicionada API `QueryMappedUnbufferedAsync()` baseada + em `DbConnection` e `IAsyncEnumerable`. +- Prompt 9.5: adicionada API + `QueryMappedUnbufferedAsync()`. +- Prompt 9.5: adicionados overloads por `CommandDefinition`. +- Prompt 9.5: cancellation propagada para `CommandDefinition`, + `DbDataReader.ReadAsync(...)` e loop de enumeracao com + `[EnumeratorCancellation]`. +- Prompt 9.5: reader descartado em `finally`, usando `DisposeAsync()` quando o + provider implementa `IAsyncDisposable` e `Dispose()` como fallback. +- Prompt 9.5: materializers generated e runtime continuam sincronos por linha; + async fica concentrado em execute/read. +- Prompt 9.5: core fixado em `LangVersion` `8.0` e dependencia publica direta + de `Microsoft.Bcl.AsyncInterfaces` 10.0.8 adicionada para + `IAsyncEnumerable` em `netstandard2.0`. +- Prompt 9.5: adicionada cobertura para async streaming normal, empty result, + nested, Value Object, profile, generated, fallback, cancellation antes da + execucao, cancellation durante enumeracao, cancellation apos consumo parcial, + partial consumption, excecoes, disposal, transaction e connection state. +- Prompt 9.5: adicionados benchmarks de Dapper async unbuffered e FluentMap + async unbuffered generated/runtime fallback. ## Em andamento @@ -141,12 +164,34 @@ Nenhuma feature produtiva em andamento. `lib/netstandard2.0/Dapper.FluentMap.dll` e `lib/netstandard2.0/Dapper.FluentMap.xml` presentes. +## Validacao do Prompt 9.5 + +- `dotnet test test\Dapper.FluentMap.Tests\Dapper.FluentMap.Tests.csproj --configuration Release --filter FullyQualifiedName~QueryMappedUnbufferedAsyncTests`: + sucesso, 15 testes aprovados. +- `dotnet restore .\Dapper.FluentMap.sln`: + sucesso. +- `dotnet build benchmarks\Dapper.FluentMap.Benchmarks\Dapper.FluentMap.Benchmarks.csproj --configuration Release`: + sucesso, 0 warnings, 0 errors. +- `dotnet build .\Dapper.FluentMap.sln --configuration Release --no-restore`: + sucesso, 0 warnings, 0 errors. +- `dotnet test .\Dapper.FluentMap.sln --configuration Release --no-build`: + sucesso, 352 testes aprovados no total. +- `dotnet run --project benchmarks\Dapper.FluentMap.Benchmarks\Dapper.FluentMap.Benchmarks.csproj --configuration Release --no-build -- --filter *MaterializationSteadyStateBenchmarks*`: + sucesso, 15 benchmarks executados; resultados registrados em + `.sdd/etapa-9/06-performance-results.md`. +- `dotnet pack .\src\Dapper.FluentMap\Dapper.FluentMap.csproj --configuration Release --no-build --output .\artifacts\packages`: + sucesso; warning legado `NU5125` sobre `licenseUrl`. +- Pacote inspecionado: + `lib/netstandard2.0/Dapper.FluentMap.dll` e + `lib/netstandard2.0/Dapper.FluentMap.xml` presentes; nuspec inclui + dependencias `Dapper` 2.1.79 e `Microsoft.Bcl.AsyncInterfaces` 10.0.8. + ## Proximos passos -1. Async streaming. -2. Lifetime/cancellation hardening para caminhos async/streaming. -3. Regression/performance complementar. -4. Documentacao final. +1. Documentacao final da Etapa 9, se solicitada. +2. Avaliar `QueryMultipleMappedAsync`/`ReadMappedUnbufferedAsync` em prompt + proprio, se houver demanda. +3. Avaliar caminho generated-only/AOT-safe em prompt proprio. ## Decisoes relevantes @@ -159,10 +204,10 @@ Nenhuma feature produtiva em andamento. - Streaming deve ter nomes explicitos com `Unbuffered`. - Prompt 9.4 implementou `QueryMappedUnbuffered*` sincrono sem misturar com async streaming. -- `IAsyncEnumerable` deve ser avaliado como mudanca de API/dependencia para - `netstandard2.0`. -- Cancellation deve usar `CommandDefinition.CancellationToken` e overloads - discoverable quando aprovados. +- Prompt 9.5 implementou `QueryMappedUnbufferedAsync*` com `IAsyncEnumerable` + como mudanca aditiva de API/dependencia para `netstandard2.0`. +- Cancellation usa `CommandDefinition.CancellationToken`, overloads + discoverable e token efetivo do async enumerator. - FluentMap nao deve abstrair SQL alem do necessario para aplicar materializacao avancada. @@ -201,8 +246,9 @@ await foreach (var customer in connection.QueryMappedUnbufferedAsync( } ``` -`QueryMappedUnbuffered*` sincrono foi implementado no Prompt 9.4. O nome async -ainda depende de revisao de overloads, target framework e compatibilidade. +`QueryMappedUnbuffered*` sincrono foi implementado no Prompt 9.4. +`QueryMappedUnbufferedAsync*` foi implementado no Prompt 9.5 com receiver +`DbConnection`. ## Riscos conhecidos @@ -212,8 +258,8 @@ ainda depende de revisao de overloads, target framework e compatibilidade. - Streaming pode manter recursos abertos por mais tempo se enumeradores nao forem descartados. - Cancellation depende do suporte real do provider. -- `IAsyncEnumerable` em API publica `netstandard2.0` pode alterar - dependencias/compatibilidade. +- `IAsyncEnumerable` em API publica `netstandard2.0` alterou a dependencia + publica ao adicionar `Microsoft.Bcl.AsyncInterfaces` 10.0.8. - SQLite pode nao cobrir todos os cenarios reais de multiple result sets. - Generated-only para AOT ainda nao existe; fallback runtime preserva warnings de trimming/dynamic code. @@ -227,6 +273,7 @@ ainda depende de revisao de overloads, target framework e compatibilidade. - `.sdd/etapa-9/04-read-mapped-spec.md` - `.sdd/etapa-9/05-unbuffered-materialization.md` - `.sdd/etapa-9/06-performance-results.md` +- `.sdd/etapa-9/07-async-streaming-spec.md` - `.sdd/etapa-9/DECISIONS.md` - `.sdd/etapa-9/STATUS.md` - `src/Dapper.FluentMap/MappedGridReader.cs` @@ -242,8 +289,9 @@ ainda depende de revisao de overloads, target framework e compatibilidade. - `test/Dapper.FluentMap.Tests/GeneratedMaterializerContractTests.cs` - `test/Dapper.FluentMap.Tests/QueryMultipleMappedTests.cs` - `test/Dapper.FluentMap.Tests/QueryMappedUnbufferedTests.cs` +- `test/Dapper.FluentMap.Tests/QueryMappedUnbufferedAsyncTests.cs` - `benchmarks/Dapper.FluentMap.Benchmarks/Program.cs` ## Ultimo prompt executado -Ultimo prompt executado: 9.4 +Ultimo prompt executado: 9.5 diff --git a/README.md b/README.md index 59b69e6..282a403 100644 --- a/README.md +++ b/README.md @@ -434,6 +434,7 @@ connection.QueryMapped(sql); connection.QueryMappedSingle(sql); connection.QueryMappedSingle(sql); connection.QueryMappedUnbuffered(sql); +connection.QueryMappedUnbufferedAsync(sql, cancellationToken); using var multi = connection.QueryMultipleMapped(sql); var customers = multi.ReadMapped(); @@ -453,6 +454,19 @@ foreach (var customer in connection.QueryMappedUnbuffered(sql)) Unbuffered queries are lazy: the command is executed when enumeration starts, not when the method is called. The underlying reader stays open until enumeration finishes or the enumerator is disposed. If FluentMap opens a closed connection for the enumeration, disposing the reader closes it again; if the connection was already open, it remains open and must stay usable for the whole enumeration. Dispose the enumerator, for example by using `foreach`, when stopping early. +Use `QueryMappedUnbufferedAsync()` or `QueryMappedUnbufferedAsync()` on `DbConnection` when the provider supports asynchronous readers: + +```csharp +await foreach (var customer in connection.QueryMappedUnbufferedAsync( + sql, + cancellationToken)) +{ + await ProcessAsync(customer, cancellationToken); +} +``` + +Async unbuffered queries are also lazy and incremental. FluentMap awaits command execution and `DbDataReader.ReadAsync(...)`, propagates cancellation to supported async operations, and disposes the reader when enumeration completes, stops early, is canceled or throws. Row materialization remains synchronous after the row has been read; generated materializers and runtime fallback use the same dispatch as buffered and synchronous unbuffered queries. + ## Dommel Install `Dapper.FluentMap.Dommel` when using [Dommel](https://github.com/henkmollema/Dommel): @@ -527,7 +541,7 @@ persistence behavior that matches the intent: `ReadOnly()`, `Computed()`, - Assembly scanning depends on reflection discovery and is not the recommended path for trimmed or Native AOT applications. - `QueryMapped*` may use generated materializers for supported flat, nested and Value Object shapes, but it can still fall back to runtime metadata and dynamic code; it is not yet a guaranteed Native AOT-safe materialization path. - Mapping profiles are selected through `QueryMapped()` and `ReadMapped()` APIs. -- `QueryMapped*` and `ReadMapped*` are buffered. Use `QueryMappedUnbuffered*` for explicit synchronous unbuffered streaming. +- `QueryMapped*` and `ReadMapped*` are buffered. Use `QueryMappedUnbuffered*` for explicit synchronous or asynchronous unbuffered streaming. - Value object construction uses matching public constructors, not factory methods. ## Contributing @@ -984,6 +998,7 @@ connection.QueryMapped(sql); connection.QueryMappedSingle(sql); connection.QueryMappedSingle(sql); connection.QueryMappedUnbuffered(sql); +connection.QueryMappedUnbufferedAsync(sql, cancellationToken); using var multi = connection.QueryMultipleMapped(sql); var customers = multi.ReadMapped(); @@ -1003,6 +1018,19 @@ foreach (var customer in connection.QueryMappedUnbuffered(sql)) Consultas unbuffered são lazy: o comando é executado quando a enumeração começa, não quando o método é chamado. O reader subjacente permanece aberto até a enumeração terminar ou o enumerator ser descartado. Se o FluentMap abrir uma conexão fechada para a enumeração, o dispose do reader fecha a conexão novamente; se a conexão já estava aberta, ela permanece aberta e precisa continuar válida durante toda a enumeração. Descarte o enumerator, por exemplo usando `foreach`, ao parar cedo. +Use `QueryMappedUnbufferedAsync()` ou `QueryMappedUnbufferedAsync()` em `DbConnection` quando o provider suportar readers assíncronos: + +```csharp +await foreach (var customer in connection.QueryMappedUnbufferedAsync( + sql, + cancellationToken)) +{ + await ProcessAsync(customer, cancellationToken); +} +``` + +Consultas async unbuffered também são lazy e incrementais. O FluentMap aguarda a execução do comando e `DbDataReader.ReadAsync(...)`, propaga cancellation para operações async suportadas e descarta o reader quando a enumeração termina, para cedo, é cancelada ou falha. A materialização da linha continua síncrona depois que a linha foi lida; materializers gerados e fallback runtime usam o mesmo dispatch dos caminhos buffered e unbuffered síncrono. + ## Dommel Instale `Dapper.FluentMap.Dommel` ao usar [Dommel](https://github.com/henkmollema/Dommel): @@ -1078,7 +1106,7 @@ ainda devem ser lidos, use o persistence behavior correspondente: - Assembly scanning depende de descoberta por reflection e não é o caminho recomendado para aplicações com trimming ou Native AOT. - `QueryMapped*` pode usar materializadores gerados para shapes flat, aninhados e Value Object suportados, mas ainda pode cair para metadados de runtime e código dinâmico; ele ainda não é um caminho de materialização garantidamente seguro para Native AOT. - Mapping profiles são selecionados pelas APIs `QueryMapped()` e `ReadMapped()`. -- `QueryMapped*` e `ReadMapped*` são bufferizados. Use `QueryMappedUnbuffered*` para streaming unbuffered síncrono explícito. +- `QueryMapped*` e `ReadMapped*` são bufferizados. Use `QueryMappedUnbuffered*` para streaming unbuffered síncrono ou assíncrono explícito. - A construção de Value Objects usa construtores públicos compatíveis, não factory methods. ## Contribuição diff --git a/benchmarks/Dapper.FluentMap.Benchmarks/Dapper.FluentMap.Benchmarks.csproj b/benchmarks/Dapper.FluentMap.Benchmarks/Dapper.FluentMap.Benchmarks.csproj index 3f1e25a..e4bf99d 100644 --- a/benchmarks/Dapper.FluentMap.Benchmarks/Dapper.FluentMap.Benchmarks.csproj +++ b/benchmarks/Dapper.FluentMap.Benchmarks/Dapper.FluentMap.Benchmarks.csproj @@ -15,6 +15,7 @@ + diff --git a/benchmarks/Dapper.FluentMap.Benchmarks/Program.cs b/benchmarks/Dapper.FluentMap.Benchmarks/Program.cs index 5aefd6d..e681f9b 100644 --- a/benchmarks/Dapper.FluentMap.Benchmarks/Program.cs +++ b/benchmarks/Dapper.FluentMap.Benchmarks/Program.cs @@ -31,7 +31,7 @@ public class MaterializationSteadyStateBenchmarks private SqliteConnection _connection = null!; [GlobalSetup] - public void GlobalSetup() + public async Task GlobalSetup() { SQLitePCL.Batteries_V2.Init(); ResetPublicFluentState(); @@ -45,11 +45,14 @@ public void GlobalSetup() DapperPure(); DapperPureUnbuffered(); + await DapperPureUnbufferedAsync(); DapperWithFluentMapRootMapping(); QueryMappedSimple(); QueryMappedSimpleUnbuffered(); + await QueryMappedSimpleUnbufferedAsync(); QueryMappedSimpleRuntimeFallback(); QueryMappedSimpleUnbufferedRuntimeFallback(); + await QueryMappedSimpleUnbufferedAsyncRuntimeFallback(); QueryMappedImmutableConstructor(); QueryMappedNestedObject(); QueryMappedNestedObjectRuntimeFallback(); @@ -82,6 +85,13 @@ public int DapperPureUnbuffered() .Count(); } + [Benchmark] + public Task DapperPureUnbufferedAsync() + { + return CountAsync(_connection.QueryUnbufferedAsync( + "SELECT Id, Name, Age, Balance, CreatedAt FROM BenchmarkRows;")); + } + [Benchmark] public int DapperWithFluentMapRootMapping() { @@ -107,6 +117,13 @@ public int QueryMappedSimpleUnbuffered() .Count(); } + [Benchmark] + public Task QueryMappedSimpleUnbufferedAsync() + { + return CountAsync(_connection.QueryMappedUnbufferedAsync( + "SELECT Id AS customer_id, Name AS full_name, Age AS customer_age, Balance AS account_balance, CreatedAt AS created_at FROM BenchmarkRows;")); + } + [Benchmark] public int QueryMappedSimpleRuntimeFallback() { @@ -123,6 +140,13 @@ public int QueryMappedSimpleUnbufferedRuntimeFallback() .Count(); } + [Benchmark] + public Task QueryMappedSimpleUnbufferedAsyncRuntimeFallback() + { + return CountAsync(_connection.QueryMappedUnbufferedAsync( + "SELECT Name AS full_name, Id AS customer_id, Age AS customer_age, Balance AS account_balance, CreatedAt AS created_at FROM BenchmarkRows;")); + } + [Benchmark] public int QueryMappedImmutableConstructor() { @@ -230,6 +254,19 @@ private static void ResetPublicFluentState() SqlMapper.SetTypeMap(type, null); } } + + private static async Task CountAsync(IAsyncEnumerable source) + { + var count = 0; + + await foreach (var item in source) + { + _ = item; + count++; + } + + return count; + } } [MemoryDiagnoser] diff --git a/src/Dapper.FluentMap/Dapper.FluentMap.csproj b/src/Dapper.FluentMap/Dapper.FluentMap.csproj index 8558dd0..b41720d 100644 --- a/src/Dapper.FluentMap/Dapper.FluentMap.csproj +++ b/src/Dapper.FluentMap/Dapper.FluentMap.csproj @@ -5,6 +5,7 @@ 2.0.0 Henk Mollema netstandard2.0 + 8.0 true c#;dapper;mapping;fluentmap https://github.com/henkmollema/Dapper-FluentMap @@ -12,5 +13,6 @@ + diff --git a/src/Dapper.FluentMap/QueryMappedExtensions.cs b/src/Dapper.FluentMap/QueryMappedExtensions.cs index 78f28c3..5a49384 100644 --- a/src/Dapper.FluentMap/QueryMappedExtensions.cs +++ b/src/Dapper.FluentMap/QueryMappedExtensions.cs @@ -1,8 +1,11 @@ using System; using System.Collections.Generic; using System.Data; +using System.Data.Common; using System.Diagnostics.CodeAnalysis; using System.Linq; +using System.Runtime.CompilerServices; +using System.Threading; using System.Threading.Tasks; using Dapper.FluentMap.Materialization; using Dapper.FluentMap.Mapping; @@ -253,6 +256,201 @@ public static IEnumerable QueryMappedUnbuffered< return ExecuteMappedUnbuffered(connection, command, typeof(TProfile)); } + /// + /// Creates a lazy asynchronous unbuffered query that materializes rows using FluentMap's opt-in nested object materializer. + /// + /// The entity type to materialize. + /// The database connection. + /// The SQL query to execute. + /// A token to cancel asynchronous execution or enumeration. + /// A lazy asynchronous sequence that keeps the underlying reader open until enumeration completes or the async enumerator is disposed. + [RequiresUnreferencedCode(QueryMappedApiAnnotations.RequiresUnreferencedCodeMessage)] + [RequiresDynamicCode(QueryMappedApiAnnotations.RequiresDynamicCodeMessage)] + public static IAsyncEnumerable QueryMappedUnbufferedAsync< + [DynamicallyAccessedMembers(QueryMappedApiAnnotations.MaterializedEntityMemberTypes)] + TEntity>( + this DbConnection connection, + string sql, + CancellationToken cancellationToken) + where TEntity : class + { + return QueryMappedUnbufferedAsync( + connection, + sql, + param: null, + transaction: null, + commandTimeout: null, + commandType: null, + cancellationToken: cancellationToken); + } + + /// + /// Creates a lazy asynchronous unbuffered query that 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. + /// A token to cancel asynchronous execution or enumeration. + /// A lazy asynchronous sequence that keeps the underlying reader open until enumeration completes or the async enumerator is disposed. + [RequiresUnreferencedCode(QueryMappedApiAnnotations.RequiresUnreferencedCodeMessage)] + [RequiresDynamicCode(QueryMappedApiAnnotations.RequiresDynamicCodeMessage)] + public static IAsyncEnumerable QueryMappedUnbufferedAsync< + [DynamicallyAccessedMembers(QueryMappedApiAnnotations.MaterializedEntityMemberTypes)] + TEntity>( + this DbConnection connection, + string sql, + object param = null, + IDbTransaction transaction = null, + int? commandTimeout = null, + CommandType? commandType = null, + CancellationToken cancellationToken = default) + where TEntity : class + { + if (connection == null) + { + throw new ArgumentNullException(nameof(connection)); + } + + if (sql == null) + { + throw new ArgumentNullException(nameof(sql)); + } + + return QueryMappedUnbufferedAsync( + connection, + new CommandDefinition(sql, param, transaction, commandTimeout, commandType, CommandFlags.None, cancellationToken)); + } + + /// + /// Creates a lazy asynchronous unbuffered query that 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. + /// A token to cancel asynchronous execution or enumeration. + /// A lazy asynchronous sequence that keeps the underlying reader open until enumeration completes or the async enumerator is disposed. + [RequiresUnreferencedCode(QueryMappedApiAnnotations.RequiresUnreferencedCodeMessage)] + [RequiresDynamicCode(QueryMappedApiAnnotations.RequiresDynamicCodeMessage)] + public static IAsyncEnumerable QueryMappedUnbufferedAsync< + [DynamicallyAccessedMembers(QueryMappedApiAnnotations.MaterializedEntityMemberTypes)] + TEntity, + TProfile>( + this DbConnection connection, + string sql, + CancellationToken cancellationToken) + where TEntity : class + where TProfile : IMappingProfile + { + return QueryMappedUnbufferedAsync( + connection, + sql, + param: null, + transaction: null, + commandTimeout: null, + commandType: null, + cancellationToken: cancellationToken); + } + + /// + /// Creates a lazy asynchronous unbuffered query that 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. + /// A token to cancel asynchronous execution or enumeration. + /// A lazy asynchronous sequence that keeps the underlying reader open until enumeration completes or the async enumerator is disposed. + [RequiresUnreferencedCode(QueryMappedApiAnnotations.RequiresUnreferencedCodeMessage)] + [RequiresDynamicCode(QueryMappedApiAnnotations.RequiresDynamicCodeMessage)] + public static IAsyncEnumerable QueryMappedUnbufferedAsync< + [DynamicallyAccessedMembers(QueryMappedApiAnnotations.MaterializedEntityMemberTypes)] + TEntity, + TProfile>( + this DbConnection connection, + string sql, + object param = null, + IDbTransaction transaction = null, + int? commandTimeout = null, + CommandType? commandType = null, + CancellationToken cancellationToken = default) + where TEntity : class + where TProfile : IMappingProfile + { + if (connection == null) + { + throw new ArgumentNullException(nameof(connection)); + } + + if (sql == null) + { + throw new ArgumentNullException(nameof(sql)); + } + + return QueryMappedUnbufferedAsync( + connection, + new CommandDefinition(sql, param, transaction, commandTimeout, commandType, CommandFlags.None, cancellationToken)); + } + + /// + /// Creates a lazy asynchronous unbuffered command that materializes rows using FluentMap's opt-in nested object materializer. + /// + /// The entity type to materialize. + /// The database connection. + /// The command to execute. + /// A lazy asynchronous sequence that keeps the underlying reader open until enumeration completes or the async enumerator is disposed. + [RequiresUnreferencedCode(QueryMappedApiAnnotations.RequiresUnreferencedCodeMessage)] + [RequiresDynamicCode(QueryMappedApiAnnotations.RequiresDynamicCodeMessage)] + public static IAsyncEnumerable QueryMappedUnbufferedAsync< + [DynamicallyAccessedMembers(QueryMappedApiAnnotations.MaterializedEntityMemberTypes)] + TEntity>( + this DbConnection connection, + CommandDefinition command) + where TEntity : class + { + if (connection == null) + { + throw new ArgumentNullException(nameof(connection)); + } + + return ExecuteMappedUnbufferedAsync(connection, command, profileType: null, command.CancellationToken); + } + + /// + /// Creates a lazy asynchronous unbuffered command that 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. + /// A lazy asynchronous sequence that keeps the underlying reader open until enumeration completes or the async enumerator is disposed. + [RequiresUnreferencedCode(QueryMappedApiAnnotations.RequiresUnreferencedCodeMessage)] + [RequiresDynamicCode(QueryMappedApiAnnotations.RequiresDynamicCodeMessage)] + public static IAsyncEnumerable QueryMappedUnbufferedAsync< + [DynamicallyAccessedMembers(QueryMappedApiAnnotations.MaterializedEntityMemberTypes)] + TEntity, + TProfile>( + this DbConnection connection, + CommandDefinition command) + where TEntity : class + where TProfile : IMappingProfile + { + if (connection == null) + { + throw new ArgumentNullException(nameof(connection)); + } + + return ExecuteMappedUnbufferedAsync(connection, command, typeof(TProfile), command.CancellationToken); + } + /// /// Executes a query and materializes exactly one row using FluentMap's opt-in nested object materializer. /// @@ -529,5 +727,73 @@ private static async Task> ExecuteMappedAsync< return MappedRowMaterializer.Materialize(reader, profileType); } } + + private static async IAsyncEnumerable ExecuteMappedUnbufferedAsync< + [DynamicallyAccessedMembers(QueryMappedApiAnnotations.MaterializedEntityMemberTypes)] + TEntity>( + DbConnection connection, + CommandDefinition command, + Type profileType, + [EnumeratorCancellation] CancellationToken cancellationToken = default) + where TEntity : class + { + if (connection == null) + { + throw new ArgumentNullException(nameof(connection)); + } + + DbDataReader reader = null; + + try + { + cancellationToken.ThrowIfCancellationRequested(); + + var effectiveCommand = WithCancellation(command, cancellationToken); + reader = await SqlMapper.ExecuteReaderAsync(connection, effectiveCommand).ConfigureAwait(false); + var materializer = MappedRowMaterializer.CreateMaterializer(reader, profileType); + + while (true) + { + cancellationToken.ThrowIfCancellationRequested(); + + if (!await reader.ReadAsync(cancellationToken).ConfigureAwait(false)) + { + yield break; + } + + yield return materializer(reader); + } + } + finally + { + if (reader != null) + { + await DisposeReaderAsync(reader).ConfigureAwait(false); + } + } + } + + private static CommandDefinition WithCancellation(CommandDefinition command, CancellationToken cancellationToken) + { + return new CommandDefinition( + command.CommandText, + command.Parameters, + command.Transaction, + command.CommandTimeout, + command.CommandType, + command.Flags, + cancellationToken); + } + + private static ValueTask DisposeReaderAsync(DbDataReader reader) + { + if (reader is IAsyncDisposable asyncDisposable) + { + return asyncDisposable.DisposeAsync(); + } + + reader.Dispose(); + return default; + } } } diff --git a/test/Dapper.FluentMap.Analyzers.Tests/Dapper.FluentMap.Analyzers.Tests.csproj b/test/Dapper.FluentMap.Analyzers.Tests/Dapper.FluentMap.Analyzers.Tests.csproj index da5e338..b29a1d8 100644 --- a/test/Dapper.FluentMap.Analyzers.Tests/Dapper.FluentMap.Analyzers.Tests.csproj +++ b/test/Dapper.FluentMap.Analyzers.Tests/Dapper.FluentMap.Analyzers.Tests.csproj @@ -8,6 +8,7 @@ + diff --git a/test/Dapper.FluentMap.Dommel.Tests/Dapper.FluentMap.Dommel.Tests.csproj b/test/Dapper.FluentMap.Dommel.Tests/Dapper.FluentMap.Dommel.Tests.csproj index 3ef78eb..e8c3fa2 100644 --- a/test/Dapper.FluentMap.Dommel.Tests/Dapper.FluentMap.Dommel.Tests.csproj +++ b/test/Dapper.FluentMap.Dommel.Tests/Dapper.FluentMap.Dommel.Tests.csproj @@ -5,6 +5,7 @@ + diff --git a/test/Dapper.FluentMap.GeneratedRegistration.Tests/Dapper.FluentMap.GeneratedRegistration.Tests.csproj b/test/Dapper.FluentMap.GeneratedRegistration.Tests/Dapper.FluentMap.GeneratedRegistration.Tests.csproj index f7b3468..03ed900 100644 --- a/test/Dapper.FluentMap.GeneratedRegistration.Tests/Dapper.FluentMap.GeneratedRegistration.Tests.csproj +++ b/test/Dapper.FluentMap.GeneratedRegistration.Tests/Dapper.FluentMap.GeneratedRegistration.Tests.csproj @@ -7,6 +7,7 @@ + diff --git a/test/Dapper.FluentMap.Generators.Tests/Dapper.FluentMap.Generators.Tests.csproj b/test/Dapper.FluentMap.Generators.Tests/Dapper.FluentMap.Generators.Tests.csproj index e1e7a3b..ccd607d 100644 --- a/test/Dapper.FluentMap.Generators.Tests/Dapper.FluentMap.Generators.Tests.csproj +++ b/test/Dapper.FluentMap.Generators.Tests/Dapper.FluentMap.Generators.Tests.csproj @@ -7,6 +7,7 @@ + diff --git a/test/Dapper.FluentMap.Tests/QueryMappedUnbufferedAsyncTests.cs b/test/Dapper.FluentMap.Tests/QueryMappedUnbufferedAsyncTests.cs new file mode 100644 index 0000000..c2df38c --- /dev/null +++ b/test/Dapper.FluentMap.Tests/QueryMappedUnbufferedAsyncTests.cs @@ -0,0 +1,659 @@ +using System; +using System.Collections.Generic; +using System.Data; +using System.IO; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Dapper; +using Dapper.FluentMap.Mapping; +using Dapper.FluentMap.Materialization; +using Microsoft.Data.Sqlite; +using Xunit; + +namespace Dapper.FluentMap.Tests +{ + public class QueryMappedUnbufferedAsyncTests + { + [Fact] + [Trait("Category", "Integration")] + public async Task QueryMappedUnbufferedAsyncShouldMaterializeFlatEntity() + { + PreTest(typeof(FlatCustomer)); + + try + { + FluentMapper.Initialize(configuration => configuration.AddMap(new FlatCustomerMap())); + + using (var connection = new SqliteConnection("Data Source=:memory:")) + { + var customers = await ToListAsync(connection.QueryMappedUnbufferedAsync( + "SELECT 1 AS customer_id, 'Ada' AS customer_name UNION ALL SELECT 2, 'Grace';", + TestContext.Current.CancellationToken)); + + Assert.Collection( + customers, + first => + { + Assert.Equal(1, first.Id); + Assert.Equal("Ada", first.Name); + }, + second => + { + Assert.Equal(2, second.Id); + Assert.Equal("Grace", second.Name); + }); + Assert.Equal(ConnectionState.Closed, connection.State); + } + } + finally + { + PreTest(typeof(FlatCustomer)); + } + } + + [Fact] + [Trait("Category", "Integration")] + public async Task QueryMappedUnbufferedAsyncShouldReturnEmptySequence() + { + PreTest(typeof(FlatCustomer)); + + try + { + FluentMapper.Initialize(configuration => configuration.AddMap(new FlatCustomerMap())); + + using (var connection = new SqliteConnection("Data Source=:memory:")) + { + var customers = await ToListAsync(connection.QueryMappedUnbufferedAsync( + "SELECT 1 AS customer_id, 'Ada' AS customer_name WHERE 1 = 0;", + TestContext.Current.CancellationToken)); + + Assert.Empty(customers); + Assert.Equal(ConnectionState.Closed, connection.State); + } + } + finally + { + PreTest(typeof(FlatCustomer)); + } + } + + [Fact] + [Trait("Category", "Integration")] + public async Task QueryMappedUnbufferedAsyncShouldMaterializeNestedObjectsAndValueObjects() + { + PreTest(typeof(ComplexCustomer)); + + try + { + FluentMapper.Initialize(configuration => configuration.AddMap(new ComplexCustomerMap())); + + using (var connection = new SqliteConnection("Data Source=:memory:")) + { + var customer = (await ToListAsync(connection.QueryMappedUnbufferedAsync( + "SELECT 13 AS customer_id, 'Sao Paulo' AS city, 'ada@example.com' AS email;", + TestContext.Current.CancellationToken))).Single(); + + Assert.Equal(13, customer.Id); + Assert.NotNull(customer.Address); + Assert.Equal("Sao Paulo", customer.Address.City); + Assert.Equal(new ComplexEmail("ada@example.com"), customer.Email); + Assert.Equal(ConnectionState.Closed, connection.State); + } + } + finally + { + PreTest(typeof(ComplexCustomer)); + } + } + + [Fact] + [Trait("Category", "Integration")] + public async Task QueryMappedUnbufferedAsyncShouldUseProfile() + { + PreTest(typeof(FlatCustomer)); + + try + { + FluentMapper.Initialize(configuration => configuration.AddProfile()); + + using (var connection = new SqliteConnection("Data Source=:memory:")) + { + var customer = (await ToListAsync(connection.QueryMappedUnbufferedAsync( + "SELECT 7 AS legacy_id, 'Legacy Ltd.' AS legal_name;", + TestContext.Current.CancellationToken))).Single(); + + Assert.Equal(7, customer.Id); + Assert.Equal("Legacy Ltd.", customer.Name); + Assert.Equal(ConnectionState.Closed, connection.State); + } + } + finally + { + PreTest(typeof(FlatCustomer)); + } + } + + [Fact] + [Trait("Category", "Integration")] + public async Task QueryMappedUnbufferedAsyncShouldUseGeneratedMaterializerWhenRegistered() + { + PreTest(typeof(FlatCustomer)); + + try + { + FluentMapper.Initialize(configuration => + { + configuration.AddMap(new FlatCustomerMap()); + configuration.AddGeneratedMaterializer( + new[] + { + GeneratedMaterializerColumn.Map("customer_id", nameof(FlatCustomer.Id)), + GeneratedMaterializerColumn.Map("customer_name", nameof(FlatCustomer.Name)) + }, + record => new FlatCustomer + { + Id = Convert.ToInt32(record.GetValue(0)), + Name = "generated:" + Convert.ToString(record.GetValue(1)) + }); + }); + + using (var connection = new SqliteConnection("Data Source=:memory:")) + { + var customer = (await ToListAsync(connection.QueryMappedUnbufferedAsync( + "SELECT 3 AS customer_id, 'Ada' AS customer_name;", + TestContext.Current.CancellationToken))).Single(); + + Assert.Equal(3, customer.Id); + Assert.Equal("generated:Ada", customer.Name); + Assert.Equal(0, FluentMapper.Registry.MaterializationPlanCacheEntryCount); + Assert.Equal(ConnectionState.Closed, connection.State); + } + } + finally + { + PreTest(typeof(FlatCustomer)); + } + } + + [Fact] + [Trait("Category", "Integration")] + public async Task QueryMappedUnbufferedAsyncShouldUseRuntimeFallbackWhenNoGeneratedMaterializerMatches() + { + PreTest(typeof(FlatCustomer)); + + try + { + FluentMapper.Initialize(configuration => configuration.AddMap(new FlatCustomerMap())); + + using (var connection = new SqliteConnection("Data Source=:memory:")) + { + var customer = (await ToListAsync(connection.QueryMappedUnbufferedAsync( + "SELECT 'Ada' AS customer_name, 1 AS customer_id;", + TestContext.Current.CancellationToken))).Single(); + + Assert.Equal(1, customer.Id); + Assert.Equal("Ada", customer.Name); + Assert.Equal(1, FluentMapper.Registry.MaterializationPlanCacheEntryCount); + Assert.Equal(ConnectionState.Closed, connection.State); + } + } + finally + { + PreTest(typeof(FlatCustomer)); + } + } + + [Fact] + [Trait("Category", "Integration")] + public async Task QueryMappedUnbufferedAsyncShouldNotExecuteUntilEnumerated() + { + PreTest(typeof(FlatCustomer)); + + try + { + FluentMapper.Initialize(configuration => configuration.AddMap(new FlatCustomerMap())); + + using (var connection = new SqliteConnection("Data Source=:memory:")) + { + var customers = connection.QueryMappedUnbufferedAsync( + "SELECT 1 AS customer_id, 'Ada' AS customer_name;", + TestContext.Current.CancellationToken); + + Assert.Equal(ConnectionState.Closed, connection.State); + + await using (customers.GetAsyncEnumerator(TestContext.Current.CancellationToken)) + { + Assert.Equal(ConnectionState.Closed, connection.State); + } + } + } + finally + { + PreTest(typeof(FlatCustomer)); + } + } + + [Fact] + [Trait("Category", "Integration")] + public async Task QueryMappedUnbufferedAsyncShouldCloseConnectionItOpenedAfterCompleteEnumeration() + { + PreTest(typeof(FlatCustomer)); + + try + { + FluentMapper.Initialize(configuration => configuration.AddMap(new FlatCustomerMap())); + + using (var connection = new SqliteConnection("Data Source=:memory:")) + { + var customers = await ToListAsync(connection.QueryMappedUnbufferedAsync( + "SELECT 1 AS customer_id, 'Ada' AS customer_name UNION ALL SELECT 2, 'Grace';", + TestContext.Current.CancellationToken)); + + Assert.Equal(2, customers.Count); + Assert.Equal(ConnectionState.Closed, connection.State); + } + } + finally + { + PreTest(typeof(FlatCustomer)); + } + } + + [Fact] + [Trait("Category", "Integration")] + public async Task QueryMappedUnbufferedAsyncShouldCloseConnectionItOpenedAfterPartialConsumption() + { + PreTest(typeof(FlatCustomer)); + + try + { + FluentMapper.Initialize(configuration => configuration.AddMap(new FlatCustomerMap())); + + using (var connection = new SqliteConnection("Data Source=:memory:")) + { + var seen = 0; + + await foreach (var customer in connection.QueryMappedUnbufferedAsync( + "SELECT 1 AS customer_id, 'Ada' AS customer_name UNION ALL SELECT 2, 'Grace';", + TestContext.Current.CancellationToken)) + { + Assert.Equal(1, customer.Id); + seen++; + break; + } + + Assert.Equal(1, seen); + Assert.Equal(ConnectionState.Closed, connection.State); + } + } + finally + { + PreTest(typeof(FlatCustomer)); + } + } + + [Fact] + [Trait("Category", "Integration")] + public async Task QueryMappedUnbufferedAsyncShouldKeepOpenConnectionOpenAfterPartialConsumption() + { + PreTest(typeof(FlatCustomer)); + + try + { + FluentMapper.Initialize(configuration => configuration.AddMap(new FlatCustomerMap())); + + using (var connection = OpenConnection()) + { + await foreach (var customer in connection.QueryMappedUnbufferedAsync( + "SELECT 1 AS customer_id, 'Ada' AS customer_name UNION ALL SELECT 2, 'Grace';", + TestContext.Current.CancellationToken)) + { + Assert.Equal(1, customer.Id); + break; + } + + Assert.Equal(ConnectionState.Open, connection.State); + } + } + finally + { + PreTest(typeof(FlatCustomer)); + } + } + + [Fact] + [Trait("Category", "Integration")] + public async Task QueryMappedUnbufferedAsyncShouldPropagateParametersAndTransaction() + { + PreTest(typeof(FlatCustomer)); + + try + { + FluentMapper.Initialize(configuration => configuration.AddMap(new FlatCustomerMap())); + + using (var connection = OpenConnection()) + { + connection.Execute("CREATE TABLE customers (customer_id INTEGER NOT NULL, customer_name TEXT NOT NULL);"); + + using (var transaction = connection.BeginTransaction()) + { + connection.Execute( + "INSERT INTO customers (customer_id, customer_name) VALUES (9, 'Transaction');", + transaction: transaction); + + var customer = (await ToListAsync(connection.QueryMappedUnbufferedAsync( + "SELECT customer_id, customer_name FROM customers WHERE customer_id = @id;", + new { id = 9 }, + transaction, + cancellationToken: TestContext.Current.CancellationToken))).Single(); + + Assert.Equal(9, customer.Id); + Assert.Equal("Transaction", customer.Name); + Assert.Equal(ConnectionState.Open, connection.State); + + transaction.Rollback(); + } + } + } + finally + { + PreTest(typeof(FlatCustomer)); + } + } + + [Fact] + [Trait("Category", "Integration")] + public async Task QueryMappedUnbufferedAsyncShouldPropagateCancellationBeforeExecution() + { + PreTest(typeof(FlatCustomer)); + + try + { + FluentMapper.Initialize(configuration => configuration.AddMap(new FlatCustomerMap())); + + using (var connection = new SqliteConnection("Data Source=:memory:")) + using (var cancellation = new CancellationTokenSource()) + { + cancellation.Cancel(); + + var rows = connection.QueryMappedUnbufferedAsync( + "SELECT 1 AS customer_id, 'Ada' AS customer_name;", + cancellation.Token); + + await Assert.ThrowsAsync(async () => + { + await foreach (var row in rows) + { + _ = row; + } + }); + + Assert.Equal(ConnectionState.Closed, connection.State); + } + } + finally + { + PreTest(typeof(FlatCustomer)); + } + } + + [Fact] + [Trait("Category", "Integration")] + public async Task QueryMappedUnbufferedAsyncShouldPropagateCancellationDuringEnumeration() + { + PreTest(typeof(FlatCustomer)); + + try + { + FluentMapper.Initialize(configuration => configuration.AddMap(new FlatCustomerMap())); + + using (var connection = new SqliteConnection("Data Source=:memory:")) + using (var cancellation = new CancellationTokenSource()) + { + await using var enumerator = connection.QueryMappedUnbufferedAsync( + "SELECT 1 AS customer_id, 'Ada' AS customer_name UNION ALL SELECT 2, 'Grace';", + cancellation.Token) + .GetAsyncEnumerator(cancellation.Token); + + Assert.True(await enumerator.MoveNextAsync()); + Assert.Equal(1, enumerator.Current.Id); + Assert.Equal(ConnectionState.Open, connection.State); + + cancellation.Cancel(); + + await Assert.ThrowsAsync(async () => + { + await enumerator.MoveNextAsync(); + }); + + Assert.Equal(ConnectionState.Closed, connection.State); + } + } + finally + { + PreTest(typeof(FlatCustomer)); + } + } + + [Fact] + [Trait("Category", "Integration")] + public async Task QueryMappedUnbufferedAsyncShouldDisposeAfterCancellationFollowingPartialEnumeration() + { + PreTest(typeof(FlatCustomer)); + + try + { + FluentMapper.Initialize(configuration => configuration.AddMap(new FlatCustomerMap())); + + using (var connection = new SqliteConnection("Data Source=:memory:")) + using (var cancellation = new CancellationTokenSource()) + { + var enumerator = connection.QueryMappedUnbufferedAsync( + "SELECT 1 AS customer_id, 'Ada' AS customer_name UNION ALL SELECT 2, 'Grace';", + cancellation.Token) + .GetAsyncEnumerator(cancellation.Token); + + try + { + Assert.True(await enumerator.MoveNextAsync()); + Assert.Equal(ConnectionState.Open, connection.State); + + cancellation.Cancel(); + + await enumerator.DisposeAsync(); + + Assert.Equal(ConnectionState.Closed, connection.State); + } + finally + { + await enumerator.DisposeAsync(); + } + } + } + finally + { + PreTest(typeof(FlatCustomer)); + } + } + + [Fact] + [Trait("Category", "Integration")] + public async Task QueryMappedUnbufferedAsyncShouldDisposeReaderWhenMaterializationThrowsMidEnumeration() + { + var databasePath = Path.Combine(Path.GetTempPath(), "DapperFluentMap-" + Guid.NewGuid().ToString("N") + ".db"); + + PreTest(typeof(ThrowingCustomer)); + + try + { + FluentMapper.Initialize(configuration => configuration.AddMap(new ThrowingCustomerMap())); + + using (var setup = new SqliteConnection("Data Source=" + databasePath)) + { + setup.Open(); + setup.Execute("CREATE TABLE customers (customer_id INTEGER NOT NULL, cpf TEXT NOT NULL);"); + setup.Execute("INSERT INTO customers (customer_id, cpf) VALUES (1, '12345678909'), (2, ''), (3, '98765432100');"); + } + + using (var connection = new SqliteConnection("Data Source=" + databasePath)) + { + await using var enumerator = connection.QueryMappedUnbufferedAsync( + "SELECT customer_id, cpf FROM customers ORDER BY customer_id;", + TestContext.Current.CancellationToken) + .GetAsyncEnumerator(TestContext.Current.CancellationToken); + + Assert.True(await enumerator.MoveNextAsync()); + Assert.Equal(1, enumerator.Current.Id); + Assert.Equal(ConnectionState.Open, connection.State); + + var exception = await Assert.ThrowsAsync(async () => + { + await enumerator.MoveNextAsync(); + }); + + Assert.IsType(exception.InnerException); + Assert.Equal(ConnectionState.Closed, connection.State); + } + } + finally + { + PreTest(typeof(ThrowingCustomer)); + SqliteConnection.ClearAllPools(); + + if (File.Exists(databasePath)) + { + File.Delete(databasePath); + } + } + } + + private static async Task> ToListAsync(IAsyncEnumerable source) + { + var results = new List(); + + await foreach (var item in source) + { + results.Add(item); + } + + return results; + } + + 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 FlatCustomer + { + public int Id { get; set; } + + public string Name { get; set; } + } + + private sealed class FlatCustomerMap : EntityMap + { + public FlatCustomerMap() + { + Map(customer => customer.Id).ToColumn("customer_id"); + Map(customer => customer.Name).ToColumn("customer_name"); + } + } + + private sealed class LegacyCustomerMap : EntityMap, IProfileMap + { + public LegacyCustomerMap() + { + Map(customer => customer.Id).ToColumn("legacy_id"); + Map(customer => customer.Name).ToColumn("legal_name"); + } + } + + private sealed class ComplexCustomer + { + public ComplexCustomer(int id, ComplexAddress address, ComplexEmail email) + { + Id = id; + Address = address; + Email = email; + } + + public int Id { get; } + + public ComplexAddress Address { get; } + + public ComplexEmail Email { get; } + } + + private sealed class ComplexAddress + { + public ComplexAddress(string city) + { + City = city; + } + + public string City { get; } + } + + private sealed record ComplexEmail(string Value); + + private sealed class ComplexCustomerMap : EntityMap + { + public ComplexCustomerMap() + { + Map(customer => customer.Id).ToColumn("customer_id"); + Map(customer => customer.Address.City).ToColumn("city"); + Map(customer => customer.Email.Value).ToColumn("email"); + } + } + + private sealed class ThrowingCustomer + { + public ThrowingCustomer(int id, ThrowingCpf cpf) + { + Id = id; + Cpf = cpf; + } + + public int Id { get; } + + public ThrowingCpf Cpf { get; } + } + + private sealed class ThrowingCustomerMap : EntityMap + { + public ThrowingCustomerMap() + { + Map(customer => customer.Id).ToColumn("customer_id"); + Map(customer => customer.Cpf.Number).ToColumn("cpf"); + } + } + + private sealed class ThrowingCpf + { + public ThrowingCpf(string number) + { + if (string.IsNullOrWhiteSpace(number)) + { + throw new ArgumentException("CPF cannot be empty.", nameof(number)); + } + + Number = number; + } + + public string Number { get; } + } + } +} From 095bdc13909b855d805777fae7f9f410349caa81 Mon Sep 17 00:00:00 2001 From: Rodrigo de Oliveira Date: Tue, 28 Jul 2026 16:12:33 -0300 Subject: [PATCH 20/49] test(query): harden advanced materialization scenarios --- .sdd/etapa-9/01-historical-query-issues.md | 22 ++ .sdd/etapa-9/06-performance-results.md | 78 ++++ .sdd/etapa-9/08-resource-lifetime-matrix.md | 28 ++ .sdd/etapa-9/09-advanced-query-regressions.md | 77 ++++ .sdd/etapa-9/STATUS.md | 55 ++- .../Dapper.FluentMap.Benchmarks/Program.cs | 36 ++ .../AdvancedQueryHardeningTests.cs | 352 ++++++++++++++++++ .../QueryMultipleMappedTests.cs | 4 +- 8 files changed, 645 insertions(+), 7 deletions(-) create mode 100644 .sdd/etapa-9/08-resource-lifetime-matrix.md create mode 100644 .sdd/etapa-9/09-advanced-query-regressions.md create mode 100644 test/Dapper.FluentMap.Tests/AdvancedQueryHardeningTests.cs diff --git a/.sdd/etapa-9/01-historical-query-issues.md b/.sdd/etapa-9/01-historical-query-issues.md index 9a828d0..d5f5fff 100644 --- a/.sdd/etapa-9/01-historical-query-issues.md +++ b/.sdd/etapa-9/01-historical-query-issues.md @@ -113,6 +113,17 @@ Implementada regressao minima para o caminho opt-in atual: O teste cobre `QueryMultipleMapped(...).ReadMapped*`, nao altera nem substitui o comportamento Dapper puro de `QueryMultiple(...).Read()`. +### Estado apos Prompt 9.6 + +A regressao passou a ter nome orientado a comportamento: + +- `MappedConventionShouldApplyToTypedReadFromMultipleResults`. + +Ela permanece ligada a issue #22 nesta documentacao SDD, mas o teste em si +expressa o contrato permanente: convencoes configuradas por entidade devem ser +aplicadas a leituras tipadas de multiplos result sets no caminho +`QueryMultipleMapped(...).ReadMapped*`. + ## Issue #43 ### Problema original @@ -209,3 +220,14 @@ Implementada regressao minima para o caminho opt-in atual: O teste modela os grids escalares/dinamicos historicos como pequenas entidades mapeadas, porque `MappedGridReader` e deliberadamente uma API de materializacao de entidades e nao uma substituicao geral para `GridReader`. + +### Estado apos Prompt 9.6 + +A regressao passou a ter nome orientado a comportamento: + +- `ExplicitMapShouldApplyToLaterTypedReadFromMultipleResults`. + +Ela permanece ligada a issue #43 nesta documentacao SDD, mas o teste em si +expressa o contrato permanente: mapeamentos explicitos devem continuar sendo +aplicados em result sets posteriores, inclusive quando os grids anteriores ja +foram consumidos. diff --git a/.sdd/etapa-9/06-performance-results.md b/.sdd/etapa-9/06-performance-results.md index bd8bc97..f3a9dec 100644 --- a/.sdd/etapa-9/06-performance-results.md +++ b/.sdd/etapa-9/06-performance-results.md @@ -146,3 +146,81 @@ Ambiente reportado pelo BenchmarkDotNet: - A diferenca esperada e pequena porque o custo dominante permanece em rows, provider/reader, column shape e objetos materializados; o async enumerator adiciona pouco no cenario medido. + +## Benchmarks finais do Prompt 9.6 + +O benchmark steady state passou a incluir cenarios representativos de +`QueryMultiple`: + +- Dapper `QueryMultiple` buffered; +- FluentMap `QueryMultipleMapped` generated; +- FluentMap `QueryMultipleMapped` runtime fallback. + +Comando executado: + +```bash +dotnet run --project benchmarks\Dapper.FluentMap.Benchmarks\Dapper.FluentMap.Benchmarks.csproj --configuration Release --no-build -- --filter *MaterializationSteadyStateBenchmarks* +``` + +Ambiente reportado pelo BenchmarkDotNet: + +- Windows 11 `10.0.26200.8875/25H2/2025Update/HudsonValley2`; +- CPU: 11th Gen Intel Core i5-1145G7; +- .NET SDK: `10.0.302`; +- Runtime: `.NET 10.0.10`; +- Dapper: `2.1.79`; +- BenchmarkDotNet: `0.15.8`; +- Job: `ShortRun`, 1000 linhas por operacao. + +| Method | Mean | StdDev | Gen0 | Gen1 | Allocated | Alloc Ratio | +| --- | ---: | ---: | ---: | ---: | ---: | ---: | +| DapperWithFluentMapRootMapping | 1.617 ms | 0.1288 ms | 66.4063 | - | 283.3 KB | 1.00 | +| QueryMappedValueObject | 1.661 ms | 0.2101 ms | 62.5000 | 19.5313 | 276.5 KB | 0.98 | +| QueryMappedValueObjectRuntimeFallback | 1.788 ms | 0.3112 ms | 136.7188 | 25.3906 | 587.99 KB | 2.08 | +| DapperPure | 1.863 ms | 0.2177 ms | 68.3594 | - | 283.17 KB | 1.00 | +| DapperQueryMultipleBuffered | 1.865 ms | 0.1218 ms | 66.4063 | 7.8125 | 284.18 KB | 1.00 | +| QueryMappedSimpleRuntimeFallback | 1.885 ms | 0.2440 ms | 85.9375 | 19.5313 | 361.58 KB | 1.28 | +| DapperPureUnbufferedAsync | 1.900 ms | 0.0383 ms | 62.5000 | - | 267.27 KB | 0.94 | +| QueryMappedNestedObject | 1.916 ms | 0.0789 ms | 70.3125 | 23.4375 | 292.47 KB | 1.03 | +| DapperPureUnbuffered | 1.933 ms | 0.1777 ms | 62.5000 | - | 266.96 KB | 0.94 | +| QueryMappedNestedObjectRuntimeFallback | 2.032 ms | 0.2998 ms | 89.8438 | 27.3438 | 377.16 KB | 1.33 | +| QueryMappedSimpleUnbufferedRuntimeFallback | 2.057 ms | 0.2421 ms | 82.0313 | - | 345.48 KB | 1.22 | +| QueryMappedSimpleUnbuffered | 2.156 ms | 0.3421 ms | 58.5938 | - | 245.17 KB | 0.87 | +| QueryMappedSimple | 2.283 ms | 0.0523 ms | 62.5000 | 11.7188 | 261.15 KB | 0.92 | +| QueryMultipleMappedSimple | 2.345 ms | 0.2091 ms | 62.5000 | 7.8125 | 263.77 KB | 0.93 | +| QueryMappedSimpleUnbufferedAsync | 2.372 ms | 0.1782 ms | 58.5938 | - | 245.59 KB | 0.87 | +| QueryMappedSimpleUnbufferedAsyncRuntimeFallback | 2.375 ms | 0.4317 ms | 82.0313 | - | 345.89 KB | 1.22 | +| QueryMultipleMappedSimpleRuntimeFallback | 2.382 ms | 0.1981 ms | 87.8906 | - | 363.07 KB | 1.28 | +| QueryMappedImmutableConstructor | 2.869 ms | 0.3252 ms | 62.5000 | 11.7188 | 261.09 KB | 0.92 | + +## Leitura final do Prompt 9.6 + +- A rodada foi bem-sucedida, mas continua sendo `ShortRun` local; tempo nao + deve ser tratado como promessa publica. +- As alocacoes confirmam o comportamento esperado: + - Dapper buffered: `283.17 KB`; + - FluentMap buffered generated: `261.15 KB`; + - FluentMap buffered runtime fallback: `361.58 KB`; + - Dapper unbuffered: `266.96 KB`; + - FluentMap unbuffered generated: `245.17 KB`; + - FluentMap unbuffered runtime fallback: `345.48 KB`. +- `QueryMultipleMappedSimple` alocou `263.77 KB`, proximo ao + `QueryMappedSimple` generated buffered (`261.15 KB`) e abaixo do Dapper + `QueryMultiple` buffered (`284.18 KB`) nesta rodada. +- `QueryMultipleMappedSimpleRuntimeFallback` alocou `363.07 KB`, alinhado ao + custo esperado do runtime fallback buffered (`361.58 KB`). +- Streaming sincrono e assincrono nao apresentam crescimento proporcional ao + tamanho total por uma `List` criada pelo FluentMap; as allocations + ficam abaixo dos caminhos buffered equivalentes para o shape simple. +- Nao foi identificada regressao de allocation em relacao aos resultados dos + Prompts 9.4 e 9.5. A variacao de tempo permaneceu alta demais para comparar + throughput com seguranca. + +## Limitacoes + +- SQLite em memoria mede pouco I/O real; async aqui mede principalmente + overhead do contrato e do provider local. +- `ShortRun` usa poucas iteracoes e e adequado como smoke de regressao, nao + como publicacao de performance. +- SQL Server e PostgreSQL nao foram medidos porque nao existe infraestrutura + provider-specific instalada neste repositorio. diff --git a/.sdd/etapa-9/08-resource-lifetime-matrix.md b/.sdd/etapa-9/08-resource-lifetime-matrix.md new file mode 100644 index 0000000..037899c --- /dev/null +++ b/.sdd/etapa-9/08-resource-lifetime-matrix.md @@ -0,0 +1,28 @@ +# Resource Lifetime Matrix + +Prompt executado em 2026-07-28. + +Esta matriz consolida o contrato de lifetime das APIs de materializacao +avancada da Etapa 9. Ela descreve ownership de recursos criados pelo +FluentMap/Dapper e a obrigacao do consumidor quando escolhe caminhos +unbuffered/streaming. + +| API | Owns command | Owns reader | Connection requirement | Early termination | Cancellation | +| --- | ------------ | ----------- | ---------------------- | ----------------- | ------------ | +| `QueryMapped()` buffered | Dapper cria e gerencia o comando usado por `SqlMapper.ExecuteReader`; FluentMap nao expoe o command. | FluentMap descarta o `IDataReader` dentro do metodo antes de retornar a lista bufferizada. | Conexao pode estar aberta ou fechada; se o provider/Dapper abrir uma conexao fechada, o reader fecha ao ser descartado. | Nao aplicavel ao consumidor; todas as linhas sao lidas antes do retorno. | Sem cancellation especifica no caminho sincronico buffered; overload por `CommandDefinition` preserva o contrato recebido pelo Dapper. | +| `QueryMappedUnbuffered()` | Dapper cria o comando quando a enumeracao comeca; FluentMap nao expoe o command. | FluentMap e dono do `IDataReader` durante a enumeracao e o descarta ao fim, early break, dispose do enumerator ou excecao. | A conexao/transacao externas precisam permanecer validas ate a enumeracao terminar; conexao ja aberta permanece aberta. | Seguro quando o enumerator e descartado; `foreach` faz isso automaticamente em `break`/excecao. | Sem cancellation assincrona; o consumidor pode parar a enumeracao e descartar o enumerator. | +| `QueryMappedUnbufferedAsync()` async streaming | Dapper cria o comando em `ExecuteReaderAsync`; o token efetivo e copiado para o `CommandDefinition`. | FluentMap e dono do `DbDataReader` durante o async iterator e usa `DisposeAsync()` quando disponivel, com fallback para `Dispose()`. | Requer `DbConnection`; conexao/transacao externas precisam permanecer validas durante todo o `await foreach`. | Seguro quando o async enumerator e descartado; `await foreach` faz isso automaticamente em `break`/excecao. | Token propagado para `CommandDefinition`, para `ReadAsync(token)` e verificado entre linhas; `OperationCanceledException` nao e convertida. | +| `QueryMultipleMapped(...)` | Dapper cria o comando usado por `SqlMapper.ExecuteReader`; `MappedGridReader` controla o reader retornado, mas nao expoe command. | `MappedGridReader` e dono do `IDataReader` ate `Dispose()` ou ate excecao durante leitura/materializacao. | Consumo sequencial; a conexao deve continuar valida ate todos os result sets necessarios serem lidos ou o wrapper ser descartado. | Dispose do wrapper apos consumo parcial fecha o reader e impede leituras posteriores. | API sincronica sem cancellation propria; overload por `CommandDefinition` preserva configuracao suportada pelo Dapper. | +| `ReadMapped()` / `ReadMappedSingle()` | Usa o command/reader ja possuido pelo `MappedGridReader`; nao cria novo comando. | Le o result set atual de forma buffered e avanca com `NextResult()`; o reader permanece vivo entre grids ate o wrapper ser consumido ou descartado. | Deve ser chamado em ordem. Nao ha suporte a leitura concorrente nem fora de ordem dentro do mesmo `MappedGridReader`. | Nao ha enumerador ativo apos o retorno, porque o grid e bufferizado; parar entre result sets exige descartar o wrapper. | Nao ha cancellation propria; falhas de provider em `Read`/`NextResult` propagam sem wrapping como erro de mapping. | + +## Limitacoes explicitas + +- `MappedGridReader` nao suporta duas leituras concorrentes no mesmo wrapper. +- O projeto nao tenta suportar uso concorrente do mesmo `SqlMapper.GridReader`, + porque esse contrato pertence ao Dapper e o reader interno nao e superficie + publica para o FluentMap. +- Streaming de multiple result sets (`ReadMappedUnbuffered*`) ainda nao foi + implementado; `QueryMultipleMapped` permanece buffered por result set. +- Cancellation real depende do provider ADO.NET. O FluentMap propaga tokens nos + pontos async que aceitam token, mas nao transforma provider sincronico em I/O + cancelavel. diff --git a/.sdd/etapa-9/09-advanced-query-regressions.md b/.sdd/etapa-9/09-advanced-query-regressions.md new file mode 100644 index 0000000..d747340 --- /dev/null +++ b/.sdd/etapa-9/09-advanced-query-regressions.md @@ -0,0 +1,77 @@ +# Advanced Query Regressions + +Prompt executado em 2026-07-28. + +Esta suite consolida cobertura permanente para os caminhos avancados de +materializacao sem transformar a Etapa 9 em matriz infinita de providers ou +tipos. Os testes diferenciam materializacao provider-independent de +comportamento ADO.NET provider-specific. + +| Scenario | Test | Status | +| -------- | ---- | ------ | +| Convencoes em multiplos result sets mapeados, regressao historica da issue #22 | `MappedConventionShouldApplyToTypedReadFromMultipleResults` | Covered | +| Mapeamento explicito aplicado em result set posterior, regressao historica da issue #43 | `ExplicitMapShouldApplyToLaterTypedReadFromMultipleResults` | Covered | +| Multiple result sets sequenciais com tipos diferentes | `ReadMappedShouldReadSequentialResultSets` | Covered | +| `ReadMappedSingle` avanca o result set apos materializar exatamente uma linha | `ReadMappedSingleShouldMaterializeExactlyOneRowAndAdvanceResultSet` | Covered | +| Profiles isolados entre result sets da mesma entidade | `ReadMappedShouldKeepDefaultAndProfileResultSetsIsolated` | Covered | +| Naming policy e convention no result set atual | `ReadMappedShouldApplyNamingPolicyAndConventionInCurrentResultSet` | Covered | +| Objetos aninhados imutaveis e Value Objects em `ReadMapped` | `ReadMappedShouldMaterializeImmutableNestedObjectsAndValueObjects` | Covered | +| Null semantics em subarvores aninhadas | `ReadMappedShouldPreserveNestedNullSemantics` | Covered | +| Generated materializer em `ReadMapped` | `ReadMappedShouldUseGeneratedMaterializerWhenRegistered` | Covered | +| Generated materializers default/profile sem colisao | `ReadMappedShouldUseGeneratedProfileMaterializersWithoutCollisions` | Covered | +| Equivalencia generated/runtime para mesmo comportamento observavel | `ReadMappedGeneratedAndRuntimeShouldReturnEquivalentResultsForSameShape` | Covered | +| Tipos representativos em reader provider-independent | `ReadMappedShouldMaterializeRepresentativeDataTypesFromProviderIndependentReader` | Covered | +| Tipos representativos em provider SQLite real | `QueryMappedShouldMaterializeRepresentativeDataTypesWithSqliteProvider` | Covered | +| Streaming sincrono flat, nested, Value Object, profile, generated e fallback | `QueryMappedUnbufferedTests` | Covered | +| Streaming assincrono flat, nested, Value Object, profile, generated, fallback e cancellation | `QueryMappedUnbufferedAsyncTests` | Covered | +| Runtime materialization cache sob queries paralelas e conexoes independentes | `QueryMappedRuntimeFallbackShouldRemainStableAcrossParallelConnections` | Covered | +| Profile cache sob async streams paralelos e conexoes independentes | `QueryMappedUnbufferedAsyncShouldRemainStableAcrossParallelProfileStreams` | Covered | +| Generated materializers + runtime fallback em `QueryMultipleMapped` com readers independentes paralelos | `QueryMultipleMappedShouldUseGeneratedAndRuntimeMaterializersOnIndependentParallelReaders` | Covered | +| Generated lookup concorrente direto no registry | `GeneratedLookupShouldRemainStableUnderConcurrentReads` | Covered | +| Generated materializer usado por queries concorrentes | `QueryMappedGeneratedMaterializerShouldRemainStableUnderConcurrentQueries` | Covered | +| Connection lifetime quando `QueryMultipleMapped` abre conexao fechada | `QueryMultipleMappedShouldCloseConnectionItOpened` | Covered | +| Connection lifetime quando `QueryMultipleMapped` recebe conexao aberta | `QueryMultipleMappedShouldKeepOpenConnectionOpenAfterDispose` | Covered | +| Early break no streaming sincrono | `QueryMappedUnbufferedShouldCloseConnectionItOpenedAfterEarlyBreak` | Covered | +| Partial consumption no streaming assincrono | `QueryMappedUnbufferedAsyncShouldCloseConnectionItOpenedAfterPartialConsumption` | Covered | +| Cancellation antes e durante streaming assincrono | `QueryMappedUnbufferedAsyncShouldPropagateCancellationBeforeExecution`, `QueryMappedUnbufferedAsyncShouldPropagateCancellationDuringEnumeration` | Covered | + +## Provider coverage + +Infraestrutura existente: + +- SQLite: coberto por `Microsoft.Data.Sqlite` em testes e benchmarks. +- Provider-independent: coberto por `DataTableReader` para multiple result + sets deterministico, sem depender de comportamento SQL de um provider real. + +Nao havia infraestrutura instalada para SQL Server ou PostgreSQL no projeto no +Prompt 9.6. Nao foram adicionados `Microsoft.Data.SqlClient`, `Npgsql`, +containers, variaveis de ambiente ou harness externo apenas para inflar a +quantidade de providers. Essa decisao preserva a proporcao do escopo e evita +testes que dependam de servicos externos por default. + +## Data types + +A cobertura representativa exercita: + +- integer; +- string; +- nullable com valor e `DBNull`; +- `DateTime`; +- `Guid`; +- `decimal`; +- enum por valor numerico; +- Value Object construido por construtor publico. + +## Concurrency + +Cobertura adicionada: + +- queries paralelas em conexoes SQLite independentes; +- cache runtime compartilhado por shape; +- profile cache em async streams paralelos; +- generated materializer e runtime fallback em `QueryMultipleMapped` com + readers independentes; +- generated lookup concorrente ja existente no registry. + +Limite documentado: nao ha suporte declarado para uso concorrente do mesmo +`MappedGridReader` ou do mesmo `SqlMapper.GridReader`. diff --git a/.sdd/etapa-9/STATUS.md b/.sdd/etapa-9/STATUS.md index 9fba7e7..9fd4b6b 100644 --- a/.sdd/etapa-9/STATUS.md +++ b/.sdd/etapa-9/STATUS.md @@ -105,6 +105,23 @@ equivalencia entre materializacao generated e runtime. partial consumption, excecoes, disposal, transaction e connection state. - Prompt 9.5: adicionados benchmarks de Dapper async unbuffered e FluentMap async unbuffered generated/runtime fallback. +- Prompt 9.6: criada matriz + `.sdd/etapa-9/08-resource-lifetime-matrix.md`. +- Prompt 9.6: criada suite documental + `.sdd/etapa-9/09-advanced-query-regressions.md`. +- Prompt 9.6: renomeadas regressoes historicas para nomes orientados a + comportamento: + `MappedConventionShouldApplyToTypedReadFromMultipleResults` e + `ExplicitMapShouldApplyToLaterTypedReadFromMultipleResults`. +- Prompt 9.6: adicionada cobertura provider-independent de tipos + representativos com `DataTableReader`. +- Prompt 9.6: adicionada cobertura provider-specific SQLite para conversoes + ADO.NET representativas. +- Prompt 9.6: adicionada cobertura de concorrencia para runtime fallback, + profile cache em async streaming, generated materializers e + `QueryMultipleMapped` em readers/conexoes independentes. +- Prompt 9.6: adicionados benchmarks de `QueryMultiple` para Dapper buffered, + FluentMap generated e FluentMap runtime fallback. ## Em andamento @@ -186,9 +203,27 @@ Nenhuma feature produtiva em andamento. `lib/netstandard2.0/Dapper.FluentMap.xml` presentes; nuspec inclui dependencias `Dapper` 2.1.79 e `Microsoft.Bcl.AsyncInterfaces` 10.0.8. +## Validacao do Prompt 9.6 + +- `dotnet test test\Dapper.FluentMap.Tests\Dapper.FluentMap.Tests.csproj --configuration Release --filter FullyQualifiedName~AdvancedQueryHardeningTests`: + sucesso, 5 testes aprovados. +- `dotnet test test\Dapper.FluentMap.Tests\Dapper.FluentMap.Tests.csproj --configuration Release --filter FullyQualifiedName~QueryMultipleMappedTests`: + sucesso, 25 testes aprovados. +- `dotnet build benchmarks\Dapper.FluentMap.Benchmarks\Dapper.FluentMap.Benchmarks.csproj --configuration Release`: + sucesso, 0 warnings, 0 errors. +- `dotnet run --project benchmarks\Dapper.FluentMap.Benchmarks\Dapper.FluentMap.Benchmarks.csproj --configuration Release --no-build -- --filter *MaterializationSteadyStateBenchmarks*`: + sucesso, 18 benchmarks executados; resultados registrados em + `.sdd/etapa-9/06-performance-results.md`. +- `dotnet restore .\Dapper.FluentMap.sln`: + sucesso. +- `dotnet build .\Dapper.FluentMap.sln --configuration Release --no-restore`: + sucesso, 0 warnings, 0 errors. +- `dotnet test .\Dapper.FluentMap.sln --configuration Release --no-build`: + sucesso, 357 testes aprovados no total. + ## Proximos passos -1. Documentacao final da Etapa 9, se solicitada. +1. Executar documentacao final da Etapa 9, se solicitada. 2. Avaliar `QueryMultipleMappedAsync`/`ReadMappedUnbufferedAsync` em prompt proprio, se houver demanda. 3. Avaliar caminho generated-only/AOT-safe em prompt proprio. @@ -210,13 +245,19 @@ Nenhuma feature produtiva em andamento. discoverable e token efetivo do async enumerator. - FluentMap nao deve abstrair SQL alem do necessario para aplicar materializacao avancada. +- Prompt 9.6 confirmou que concorrencia suportada e entre operacoes/readers/ + conexoes independentes. Uso concorrente do mesmo `MappedGridReader` ou do + mesmo `SqlMapper.GridReader` nao e contrato suportado. ## Issues historicas - #22: conventions em multiple results; historico inconclusivo, mas sem - regressao dedicada atual. + regressao coberta por + `MappedConventionShouldApplyToTypedReadFromMultipleResults`. - #43: `QueryMultiple().Read()` nao aplicava mappings em 1.5.x; reporter - confirmou que 1.4.1 funcionava; mantenedor marcou corrigida em 1.7.0. + confirmou que 1.4.1 funcionava; mantenedor marcou corrigida em 1.7.0; + regressao coberta por + `ExplicitMapShouldApplyToLaterTypedReadFromMultipleResults`. - #42: multi-mapping por `splitOn` em unico result set, relacionado historicamente mas fora do escopo automatico da Etapa 9. - #62: plano v2 cita #42 e #43 como relacionadas a melhorias de type mapping. @@ -260,7 +301,8 @@ await foreach (var customer in connection.QueryMappedUnbufferedAsync( - Cancellation depende do suporte real do provider. - `IAsyncEnumerable` em API publica `netstandard2.0` alterou a dependencia publica ao adicionar `Microsoft.Bcl.AsyncInterfaces` 10.0.8. -- SQLite pode nao cobrir todos os cenarios reais de multiple result sets. +- SQLite cobre provider-specific ADO behavior disponivel na infraestrutura + atual, mas nao substitui certificacao SQL Server/PostgreSQL. - Generated-only para AOT ainda nao existe; fallback runtime preserva warnings de trimming/dynamic code. - Tests de estado global precisam resetar FluentMapper e type maps por tipo. @@ -274,6 +316,8 @@ await foreach (var customer in connection.QueryMappedUnbufferedAsync( - `.sdd/etapa-9/05-unbuffered-materialization.md` - `.sdd/etapa-9/06-performance-results.md` - `.sdd/etapa-9/07-async-streaming-spec.md` +- `.sdd/etapa-9/08-resource-lifetime-matrix.md` +- `.sdd/etapa-9/09-advanced-query-regressions.md` - `.sdd/etapa-9/DECISIONS.md` - `.sdd/etapa-9/STATUS.md` - `src/Dapper.FluentMap/MappedGridReader.cs` @@ -290,8 +334,9 @@ await foreach (var customer in connection.QueryMappedUnbufferedAsync( - `test/Dapper.FluentMap.Tests/QueryMultipleMappedTests.cs` - `test/Dapper.FluentMap.Tests/QueryMappedUnbufferedTests.cs` - `test/Dapper.FluentMap.Tests/QueryMappedUnbufferedAsyncTests.cs` +- `test/Dapper.FluentMap.Tests/AdvancedQueryHardeningTests.cs` - `benchmarks/Dapper.FluentMap.Benchmarks/Program.cs` ## Ultimo prompt executado -Ultimo prompt executado: 9.5 +Ultimo prompt executado: 9.6 diff --git a/benchmarks/Dapper.FluentMap.Benchmarks/Program.cs b/benchmarks/Dapper.FluentMap.Benchmarks/Program.cs index e681f9b..1397788 100644 --- a/benchmarks/Dapper.FluentMap.Benchmarks/Program.cs +++ b/benchmarks/Dapper.FluentMap.Benchmarks/Program.cs @@ -58,6 +58,9 @@ public async Task GlobalSetup() QueryMappedNestedObjectRuntimeFallback(); QueryMappedValueObject(); QueryMappedValueObjectRuntimeFallback(); + DapperQueryMultipleBuffered(); + QueryMultipleMappedSimple(); + QueryMultipleMappedSimpleRuntimeFallback(); } [GlobalCleanup] @@ -187,6 +190,39 @@ public int QueryMappedValueObjectRuntimeFallback() .Count(); } + [Benchmark] + public int DapperQueryMultipleBuffered() + { + using var multi = _connection.QueryMultiple( + @"SELECT Id, Name, Age, Balance, CreatedAt FROM BenchmarkRows WHERE Id <= 500; + SELECT Id, Name, Age, Balance, CreatedAt FROM BenchmarkRows WHERE Id > 500;"); + + return multi.Read().AsList().Count + + multi.Read().AsList().Count; + } + + [Benchmark] + public int QueryMultipleMappedSimple() + { + using var multi = _connection.QueryMultipleMapped( + @"SELECT Id AS customer_id, Name AS full_name, Age AS customer_age, Balance AS account_balance, CreatedAt AS created_at FROM BenchmarkRows WHERE Id <= 500; + SELECT Id AS customer_id, Name AS full_name, Age AS customer_age, Balance AS account_balance, CreatedAt AS created_at FROM BenchmarkRows WHERE Id > 500;"); + + return multi.ReadMapped().Count() + + multi.ReadMapped().Count(); + } + + [Benchmark] + public int QueryMultipleMappedSimpleRuntimeFallback() + { + using var multi = _connection.QueryMultipleMapped( + @"SELECT Name AS full_name, Id AS customer_id, Age AS customer_age, Balance AS account_balance, CreatedAt AS created_at FROM BenchmarkRows WHERE Id <= 500; + SELECT Name AS full_name, Id AS customer_id, Age AS customer_age, Balance AS account_balance, CreatedAt AS created_at FROM BenchmarkRows WHERE Id > 500;"); + + return multi.ReadMapped().Count() + + multi.ReadMapped().Count(); + } + private static SqliteConnection OpenPopulatedConnection() { var connection = new SqliteConnection("Data Source=:memory:"); diff --git a/test/Dapper.FluentMap.Tests/AdvancedQueryHardeningTests.cs b/test/Dapper.FluentMap.Tests/AdvancedQueryHardeningTests.cs new file mode 100644 index 0000000..306773e --- /dev/null +++ b/test/Dapper.FluentMap.Tests/AdvancedQueryHardeningTests.cs @@ -0,0 +1,352 @@ +using System; +using System.Collections.Generic; +using System.Data; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Dapper; +using Dapper.FluentMap.Mapping; +using Dapper.FluentMap.Materialization; +using Microsoft.Data.Sqlite; +using Xunit; + +namespace Dapper.FluentMap.Tests +{ + public class AdvancedQueryHardeningTests + { + [Fact] + public void ReadMappedShouldMaterializeRepresentativeDataTypesFromProviderIndependentReader() + { + PreTest(typeof(RepresentativeRecord)); + + try + { + var id = new Guid("42f74f8f-2e12-4ca7-9c0f-46973f89dd65"); + var createdAt = new DateTime(2024, 5, 6, 7, 8, 9, DateTimeKind.Utc); + + FluentMapper.Initialize(configuration => configuration.AddMap(new RepresentativeRecordMap())); + + using (var reader = CreateReader(CreateTable( + new[] + { + "record_id", + "display_name", + "optional_count", + "missing_count", + "created_at", + "external_id", + "amount", + "status", + "email" + }, + new object[] { 42, "Ada", 7, DBNull.Value, createdAt, id, 123.45m, 2, "ada@example.com" }))) + using (var multi = new MappedGridReader(reader)) + { + var row = multi.ReadMappedSingle(); + + Assert.Equal(42, row.Id); + Assert.Equal("Ada", row.Name); + Assert.Equal(7, row.OptionalCount); + Assert.Null(row.MissingCount); + Assert.Equal(createdAt, row.CreatedAt); + Assert.Equal(id, row.ExternalId); + Assert.Equal(123.45m, row.Amount); + Assert.Equal(RepresentativeStatus.Active, row.Status); + Assert.Equal(new RepresentativeEmail("ada@example.com"), row.Email); + Assert.True(multi.IsConsumed); + } + } + finally + { + PreTest(typeof(RepresentativeRecord)); + } + } + + [Fact] + [Trait("Category", "Integration")] + public void QueryMappedShouldMaterializeRepresentativeDataTypesWithSqliteProvider() + { + PreTest(typeof(RepresentativeRecord)); + + try + { + var id = new Guid("d9112374-bc21-4396-a7c8-d4f2d1212f47"); + + FluentMapper.Initialize(configuration => configuration.AddMap(new RepresentativeRecordMap())); + + using (var connection = new SqliteConnection("Data Source=:memory:")) + { + var row = connection.QueryMappedSingle( + @"SELECT + 42 AS record_id, + 'Ada' AS display_name, + 7 AS optional_count, + NULL AS missing_count, + '2024-05-06T07:08:09' AS created_at, + 'd9112374-bc21-4396-a7c8-d4f2d1212f47' AS external_id, + 123.45 AS amount, + 2 AS status, + 'ada@example.com' AS email;"); + + Assert.Equal(42, row.Id); + Assert.Equal("Ada", row.Name); + Assert.Equal(7, row.OptionalCount); + Assert.Null(row.MissingCount); + Assert.Equal(new DateTime(2024, 5, 6, 7, 8, 9), row.CreatedAt); + Assert.Equal(id, row.ExternalId); + Assert.Equal(123.45m, row.Amount); + Assert.Equal(RepresentativeStatus.Active, row.Status); + Assert.Equal(new RepresentativeEmail("ada@example.com"), row.Email); + Assert.Equal(ConnectionState.Closed, connection.State); + } + } + finally + { + PreTest(typeof(RepresentativeRecord)); + } + } + + [Fact] + [Trait("Category", "Integration")] + public void QueryMappedRuntimeFallbackShouldRemainStableAcrossParallelConnections() + { + PreTest(typeof(ConcurrentCustomer)); + + try + { + FluentMapper.Initialize(configuration => configuration.AddMap(new ConcurrentCustomerMap())); + + var results = Enumerable.Range(0, 40) + .AsParallel() + .Select(index => + { + using (var connection = new SqliteConnection("Data Source=:memory:")) + { + var customer = connection.QueryMappedSingle( + $"SELECT 'customer-{index}' AS customer_name, {index} AS customer_id;"); + + return customer.Id == index && customer.Name == $"customer-{index}"; + } + }) + .ToList(); + + Assert.All(results, Assert.True); + Assert.Equal(1, FluentMapper.Registry.MaterializationPlanCacheEntryCount); + } + finally + { + PreTest(typeof(ConcurrentCustomer)); + } + } + + [Fact] + [Trait("Category", "Integration")] + public async Task QueryMappedUnbufferedAsyncShouldRemainStableAcrossParallelProfileStreams() + { + PreTest(typeof(ConcurrentCustomer)); + + try + { + FluentMapper.Initialize(configuration => configuration.AddProfile()); + + var tasks = Enumerable.Range(0, 24) + .Select(index => MaterializeProfileStreamAsync(index, TestContext.Current.CancellationToken)) + .ToArray(); + + var results = await Task.WhenAll(tasks); + + Assert.All(results, Assert.True); + Assert.Equal(1, FluentMapper.Registry.MaterializationPlanCacheEntryCount); + } + finally + { + PreTest(typeof(ConcurrentCustomer)); + } + } + + [Fact] + public void QueryMultipleMappedShouldUseGeneratedAndRuntimeMaterializersOnIndependentParallelReaders() + { + PreTest(typeof(ConcurrentCustomer)); + + try + { + var generatedRows = 0; + + FluentMapper.Initialize(configuration => + { + configuration.AddMap(new ConcurrentCustomerMap()); + configuration.AddGeneratedMaterializer( + new[] + { + GeneratedMaterializerColumn.Map("customer_id", nameof(ConcurrentCustomer.Id)), + GeneratedMaterializerColumn.Map("customer_name", nameof(ConcurrentCustomer.Name)) + }, + record => + { + Interlocked.Increment(ref generatedRows); + return new ConcurrentCustomer + { + Id = Convert.ToInt32(record.GetValue(0)), + Name = Convert.ToString(record.GetValue(1)) + }; + }); + }); + + var results = Enumerable.Range(0, 30) + .AsParallel() + .Select(index => + { + using (var reader = CreateReader( + CreateTable( + new[] { "customer_id", "customer_name" }, + new object[] { index, "generated-" + index }), + CreateTable( + new[] { "customer_name", "customer_id" }, + new object[] { "runtime-" + index, index }))) + using (var multi = new MappedGridReader(reader)) + { + var generated = multi.ReadMappedSingle(); + var runtime = multi.ReadMappedSingle(); + + return generated.Id == index && + generated.Name == "generated-" + index && + runtime.Id == index && + runtime.Name == "runtime-" + index && + multi.IsConsumed; + } + }) + .ToList(); + + Assert.All(results, Assert.True); + Assert.Equal(30, generatedRows); + Assert.Equal(1, FluentMapper.Registry.MaterializationPlanCacheEntryCount); + } + finally + { + PreTest(typeof(ConcurrentCustomer)); + } + } + + private static async Task MaterializeProfileStreamAsync(int index, CancellationToken cancellationToken) + { + using (var connection = new SqliteConnection("Data Source=:memory:")) + { + var rows = new List(); + + await foreach (var customer in connection.QueryMappedUnbufferedAsync( + $"SELECT {index} AS legacy_id, 'legacy-{index}' AS legal_name;", + cancellationToken)) + { + rows.Add(customer); + } + + var row = Assert.Single(rows); + return row.Id == index && + row.Name == $"legacy-{index}" && + connection.State == ConnectionState.Closed; + } + } + + private static DataTableReader CreateReader(params DataTable[] tables) + { + return new DataTableReader(tables); + } + + private static DataTable CreateTable(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; + } + + private static void PreTest(params Type[] types) + { + FluentMapper.Reset(types); + } + + private sealed class RepresentativeRecord + { + public int Id { get; set; } + + public string Name { get; set; } + + public int? OptionalCount { get; set; } + + public int? MissingCount { get; set; } + + public DateTime CreatedAt { get; set; } + + public Guid ExternalId { get; set; } + + public decimal Amount { get; set; } + + public RepresentativeStatus Status { get; set; } + + public RepresentativeEmail Email { get; set; } + } + + private sealed record RepresentativeEmail(string Value); + + private enum RepresentativeStatus + { + Unknown = 0, + Draft = 1, + Active = 2 + } + + private sealed class RepresentativeRecordMap : EntityMap + { + public RepresentativeRecordMap() + { + Map(record => record.Id).ToColumn("record_id"); + Map(record => record.Name).ToColumn("display_name"); + Map(record => record.OptionalCount).ToColumn("optional_count"); + Map(record => record.MissingCount).ToColumn("missing_count"); + Map(record => record.CreatedAt).ToColumn("created_at"); + Map(record => record.ExternalId).ToColumn("external_id"); + Map(record => record.Amount).ToColumn("amount"); + Map(record => record.Status).ToColumn("status"); + Map(record => record.Email.Value).ToColumn("email"); + } + } + + private sealed class ConcurrentProfile : IMappingProfile + { + } + + private sealed class ConcurrentCustomer + { + public int Id { get; set; } + + public string Name { get; set; } + } + + private sealed class ConcurrentCustomerMap : EntityMap + { + public ConcurrentCustomerMap() + { + Map(customer => customer.Id).ToColumn("customer_id"); + Map(customer => customer.Name).ToColumn("customer_name"); + } + } + + private sealed class ConcurrentCustomerProfileMap : EntityMap, IProfileMap + { + public ConcurrentCustomerProfileMap() + { + Map(customer => customer.Id).ToColumn("legacy_id"); + Map(customer => customer.Name).ToColumn("legal_name"); + } + } + } +} diff --git a/test/Dapper.FluentMap.Tests/QueryMultipleMappedTests.cs b/test/Dapper.FluentMap.Tests/QueryMultipleMappedTests.cs index 27b45ab..2ac93f8 100644 --- a/test/Dapper.FluentMap.Tests/QueryMultipleMappedTests.cs +++ b/test/Dapper.FluentMap.Tests/QueryMultipleMappedTests.cs @@ -607,7 +607,7 @@ public void QueryMappedAndReadMappedShouldReturnEquivalentResults() } [Fact] - public void HistoricalIssue22ReadMappedShouldApplyConventionsAcrossMultipleResultSets() + public void MappedConventionShouldApplyToTypedReadFromMultipleResults() { PreTest(typeof(ConventionCustomer), typeof(ConventionOrder)); @@ -644,7 +644,7 @@ public void HistoricalIssue22ReadMappedShouldApplyConventionsAcrossMultipleResul } [Fact] - public void HistoricalIssue43ReadMappedShouldApplyExplicitMapOnLaterResultSet() + public void ExplicitMapShouldApplyToLaterTypedReadFromMultipleResults() { PreTest(typeof(HistoricalRow), typeof(HistoricalTotal), typeof(HistoricalColumn)); From 3d36d17b09cf48c652e450a795bce1a9b953c11c Mon Sep 17 00:00:00 2001 From: Rodrigo de Oliveira Date: Tue, 28 Jul 2026 16:26:47 -0300 Subject: [PATCH 21/49] docs(query): finalize advanced materialization support --- .sdd/etapa-9/01-historical-query-issues.md | 39 +++ .sdd/etapa-9/06-performance-results.md | 21 ++ .sdd/etapa-9/FINAL-REPORT.md | 354 +++++++++++++++++++++ .sdd/etapa-9/STATUS.md | 54 +++- README.md | 74 ++++- 5 files changed, 531 insertions(+), 11 deletions(-) create mode 100644 .sdd/etapa-9/FINAL-REPORT.md diff --git a/.sdd/etapa-9/01-historical-query-issues.md b/.sdd/etapa-9/01-historical-query-issues.md index d5f5fff..a85271b 100644 --- a/.sdd/etapa-9/01-historical-query-issues.md +++ b/.sdd/etapa-9/01-historical-query-issues.md @@ -124,6 +124,24 @@ expressa o contrato permanente: convencoes configuradas por entidade devem ser aplicadas a leituras tipadas de multiplos result sets no caminho `QueryMultipleMapped(...).ReadMapped*`. +### Estado final da Etapa 9 + +Classificacao: + +- `Regression covered`; +- `Resolved by implementation` para o caminho opt-in + `QueryMultipleMapped(...).ReadMapped*`; +- `Resolved by architecture` quanto a decisao de nao depender dos internals de + `SqlMapper.GridReader`. + +Evidencia: + +- API publica `QueryMultipleMapped(...)` e `ReadMapped()`; +- teste `MappedConventionShouldApplyToTypedReadFromMultipleResults`; +- documentacao publica e matriz de lifetime registram consumo sequencial e + separacao entre Dapper `QueryMultiple` puro e materializacao avancada do + FluentMap. + ## Issue #43 ### Problema original @@ -231,3 +249,24 @@ Ela permanece ligada a issue #43 nesta documentacao SDD, mas o teste em si expressa o contrato permanente: mapeamentos explicitos devem continuar sendo aplicados em result sets posteriores, inclusive quando os grids anteriores ja foram consumidos. + +### Estado final da Etapa 9 + +Classificacao: + +- `Regression covered`; +- `Resolved by implementation` para o caminho opt-in + `QueryMultipleMapped(...).ReadMapped*`; +- `Already resolved previously` para o relato historico do caminho Dapper puro, + conforme o encerramento upstream em 1.7.0; +- `Resolved by architecture` quanto a separacao entre Dapper `GridReader` e + `MappedGridReader`. + +Evidencia: + +- teste `ExplicitMapShouldApplyToLaterTypedReadFromMultipleResults`; +- terceiro result set com colunas equivalentes ao relato historico: + `column_prefix`, `column_name`, `display_order`, `can_be_ordered`, + `can_be_filtered` e `column_width_in_pixels`; +- cobertura adicional de profiles, generated/runtime fallback e lifetime em + `QueryMultipleMappedTests`. diff --git a/.sdd/etapa-9/06-performance-results.md b/.sdd/etapa-9/06-performance-results.md index f3a9dec..3667821 100644 --- a/.sdd/etapa-9/06-performance-results.md +++ b/.sdd/etapa-9/06-performance-results.md @@ -224,3 +224,24 @@ Ambiente reportado pelo BenchmarkDotNet: como publicacao de performance. - SQL Server e PostgreSQL nao foram medidos porque nao existe infraestrutura provider-specific instalada neste repositorio. + +## Auditoria final do Prompt 9.7 + +Comando reexecutado: + +```bash +dotnet run --project benchmarks\Dapper.FluentMap.Benchmarks\Dapper.FluentMap.Benchmarks.csproj --configuration Release --no-build -- --filter *MaterializationSteadyStateBenchmarks* +``` + +Resultado: sucesso, 18 cenarios executados. + +A rodada confirmou a mesma leitura de allocations registrada no Prompt 9.6: + +- Dapper buffered, unbuffered e async unbuffered permanecem como baseline. +- FluentMap generated e runtime fallback estao separados em buffered, + unbuffered, async unbuffered e QueryMultiple. +- As allocations do streaming continuam abaixo dos equivalentes buffered para o + shape simple, coerentes com a ausencia de `List` interna do + FluentMap. +- Tempo local continua ruidoso em `ShortRun`; a documentacao publica nao deve + fazer claims promocionais de throughput. diff --git a/.sdd/etapa-9/FINAL-REPORT.md b/.sdd/etapa-9/FINAL-REPORT.md new file mode 100644 index 0000000..0a9e46a --- /dev/null +++ b/.sdd/etapa-9/FINAL-REPORT.md @@ -0,0 +1,354 @@ +# Etapa 9 - Final Report + +## Objetivo + +Encerrar a Etapa 9 - Advanced Query Materialization com auditoria final da +implementacao, documentacao publica, validacao de testes, evidencia de +performance e registro claro de limites. A etapa adicionou materializacao +avancada para multiple result sets, streaming sincronico, async streaming, +cancellation e integracao com materializadores gerados, sem iniciar recursos da +Etapa 10. + +## Implementado + +- `QueryMultipleMapped(...)` como wrapper proprio para multiple result sets no + caminho opt-in do FluentMap. +- `MappedGridReader` com `ReadMapped()`, + `ReadMapped()`, `ReadMappedSingle()` e + `ReadMappedSingle()`. +- Dispatch compartilhado por `MappedRowMaterializer`, tentando generated + materializer antes do fallback runtime. +- `QueryMappedUnbuffered()` e + `QueryMappedUnbuffered()` para streaming sincronico lazy. +- `QueryMappedUnbufferedAsync()` e + `QueryMappedUnbufferedAsync()` para streaming assincrono + lazy via `DbConnection` e `IAsyncEnumerable`. +- Cancellation em async streaming por `CommandDefinition`, token efetivo do + enumerator e `DbDataReader.ReadAsync(...)`. +- Cobertura de regressao historica para issues #22 e #43. +- Benchmarks steady state para buffered, unbuffered, async unbuffered, + generated/runtime fallback e `QueryMultiple`. + +## Audit SDD + +| Requirement | Implementation | Tests | Performance | Status | +| ----------- | -------------- | ----- | ----------- | ------ | +| Multiple result sets no caminho opt-in | `QueryMultipleMapped(...)` retorna `MappedGridReader` e usa `IDataReader.NextResult()` | `QueryMultipleMappedTests` | `DapperQueryMultipleBuffered`, `QueryMultipleMappedSimple`, `QueryMultipleMappedSimpleRuntimeFallback` | Completed | +| Leitura por entidade e profile em grids distintos | `ReadMapped()` e `ReadMapped()` por result set | `ReadMappedShouldKeepDefaultAndProfileResultSetsIsolated`, profile generated tests | Coberto funcionalmente; sem benchmark de profile separado | Completed | +| Precedencia explicit mapping, convention, Dapper default | Runtime plan usa registry antes de `DefaultTypeMap`; conventions/naming policies cobertas | naming policy, convention e issue #22 regressions | Nao benchmarkado isoladamente | Completed | +| Generated antes de runtime fallback por shape ordenado | `MappedRowMaterializer.CreateMaterializer` chama `TryGetGeneratedMaterializer` antes de `GetMaterializationPlan` | generated/default/profile/fallback/equivalence tests | generated vs runtime fallback em buffered, unbuffered e QueryMultiple | Completed | +| Buffered materialization preservada | `QueryMapped*` e `ReadMapped*` retornam resultados ja materializados | `ReadMappedShouldReturnEmptyCollectionForEmptyResultSet`, equivalence tests | Buffered comparado contra Dapper | Completed | +| Streaming sincronico explicito | `QueryMappedUnbuffered*` retorna `IEnumerable` lazy | `QueryMappedUnbufferedTests` | Dapper unbuffered vs FluentMap unbuffered generated/runtime | Completed | +| `ReadMappedUnbuffered()` em multiple results | Nao implementado; `QueryMultipleMapped` permanece buffered por grid | Limitacao documentada em lifetime/final report | Nao aplicavel | Deferred | +| Async streaming por `IAsyncEnumerable` | `QueryMappedUnbufferedAsync*` em `DbConnection` | `QueryMappedUnbufferedAsyncTests` | Dapper async unbuffered vs FluentMap async unbuffered | Completed | +| `QueryMultipleMappedAsync` | Nao implementado; requer design proprio de async multiple result lifetime | Registrado como item adiado | Nao aplicavel | Deferred | +| Cancellation async | Token propagado para command, read async e loop | cancellation before/during/partial tests | Nao benchmarkado; provider-dependent | Completed | +| Connection lifetime | Dispose de reader preserva conexao inicialmente aberta/fechada conforme Dapper/provider | connection state tests em QueryMultiple, sync/async streaming | Nao benchmarkado diretamente | Completed | +| Reader lifetime e early termination | Readers descartados ao fim, early break, dispose, cancellation e excecao | disposal/early break/exception tests | Allocations de unbuffered indicam ausencia de `List` do FluentMap | Completed | +| Command lifetime | Comando e criado pelo Dapper; FluentMap possui o reader retornado e libera recursos por dispose do reader | Coberto indiretamente por lifetime/transaction tests | Nao aplicavel | Partial | +| Exception semantics | Argument null, disposed, no remaining result sets e mapping exceptions preservados | exception/dispose/profile/mapping tests | Nao aplicavel | Completed | +| Null semantics | Nested subtree e Value Object nullable preservam semantica runtime existente | nested null tests, representative data type tests | Nao benchmarkado isoladamente | Completed | +| Provider independence | Producao usa `IDbConnection`, `DbConnection`, `IDataReader`, `DbDataReader`, `CommandDefinition` e Dapper publico | `DataTableReader` + SQLite provider tests | SQLite em memoria | Completed | +| SQL Server/PostgreSQL provider certification | Nao havia infraestrutura instalada; nao foram adicionados servicos externos | Documentado como limite | Nao aplicavel | Deferred | +| Dapper multi-mapping por `splitOn` | Explicitamente fora de escopo | Limitacoes e README | Nao aplicavel | Not applicable | +| Graph aggregation/identity map | Explicitamente fora de escopo | Limitacoes e README | Nao aplicavel | Not applicable | +| Native AOT seguro para `QueryMapped*` | APIs continuam anotadas porque fallback runtime pode ocorrer | Build/trim context herdado da Etapa 7 | Nao aplicavel | Partial | + +Nao foram encontradas divergencias produtivas que exigissem redesign no +fechamento. A diferenca mais importante e a de command lifetime: a +implementacao usa `SqlMapper.ExecuteReader` e o FluentMap controla o reader +retornado, enquanto o command fica encapsulado pelo comportamento publico do +Dapper/provider. Isso e aceitavel para a etapa, mas deve permanecer documentado +como ownership indireto. + +## API Review + +APIs publicas adicionadas: + +- `MappedGridReader`; +- `MappedGridReader.IsConsumed`; +- `MappedGridReader.ReadMapped()`; +- `MappedGridReader.ReadMapped()`; +- `MappedGridReader.ReadMappedSingle()`; +- `MappedGridReader.ReadMappedSingle()`; +- `QueryMappedExtensions.QueryMultipleMapped(...)`; +- `QueryMappedExtensions.QueryMappedUnbuffered(...)`; +- `QueryMappedExtensions.QueryMappedUnbuffered(...)`; +- `QueryMappedExtensions.QueryMappedUnbufferedAsync(...)`; +- `QueryMappedExtensions.QueryMappedUnbufferedAsync(...)`. + +Revisao final: + +- Naming esta alinhado ao vocabulario Dapper (`QueryMultiple`) e ao contrato de + lifetime (`Unbuffered`). +- Overloads por `CommandDefinition` sao justificaveis para parametros, + transacao, timeout, command type, flags e cancellation. +- `DbConnection` no async e consistente com a necessidade real de + `DbDataReader.ReadAsync(...)`. +- `ReadMappedSingleOrDefault*` nao foi adicionado porque nao ha equivalente + `QueryMappedSingleOrDefault*` no projeto. +- Nao ha streaming de grids em `MappedGridReader`; esse e um item adiado, nao + um comportamento escondido. + +Nenhuma correcao pequena de API foi necessaria no prompt final. + +## QueryMultiple + +`QueryMultipleMapped(...)` cria um `MappedGridReader` usando API publica do +Dapper e consome grids sequencialmente. Cada chamada `ReadMapped*` captura o +shape do grid atual, resolve o materializador e bufferiza o grid antes de +avancar para o proximo result set. + +Coberto por testes: + +- multiplos result sets; +- tipos diferentes por grid; +- profiles; +- generated materializer; +- runtime fallback; +- nested objects; +- immutable objects; +- Value Objects; +- empty result sets; +- dispose antes/depois de consumo; +- leitura apos dispose; +- leitura apos ultimo result set; +- excecoes de materializacao; +- parametros, transacao e lifetime de conexao. + +## ReadMapped + +`ReadMapped()` e `ReadMapped()` sao buffered, apesar de +retornarem `IEnumerable`. `ReadMappedSingle*` aplica semantica de +`Single()` depois de o grid atual ser materializado e avancado. + +Nao ha suporte a leitura concorrente ou fora de ordem dentro do mesmo +`MappedGridReader`. + +## Mapping Profiles + +Profiles sao selecionados por operacao: + +- `QueryMapped()`; +- `ReadMapped()`; +- `QueryMappedUnbuffered()`; +- `QueryMappedUnbufferedAsync()`. + +Eles nao substituem o type map global do Dapper e possuem chaves separadas de +cache/materializer por entity + profile + shape. + +## Unbuffered Materialization + +`QueryMappedUnbuffered*` e lazy: a chamada publica valida argumentos, mas o +comando e executado apenas quando a enumeracao comeca. O reader permanece +aberto durante a enumeracao e e descartado ao final, em early break, em dispose +explicito do enumerator ou em excecao. + +As allocations medidas mostram a reducao esperada pela ausencia de uma +`List` interna do FluentMap no shape simple: + +- FluentMap buffered generated: `261.15 KB`; +- FluentMap unbuffered generated: `245.17 KB`; +- FluentMap buffered runtime fallback: `361.58 KB`; +- FluentMap unbuffered runtime fallback: `345.48 KB`. + +## Async Streaming + +`QueryMappedUnbufferedAsync*` retorna `IAsyncEnumerable` e exige +`DbConnection`. A execucao ocorre no primeiro `MoveNextAsync()`. + +O caminho async: + +- usa `SqlMapper.ExecuteReaderAsync`; +- resolve o materializer uma vez por shape; +- chama `DbDataReader.ReadAsync(cancellationToken)` por linha; +- materializa a linha de forma sincrona apos a leitura; +- descarta o reader em `finally`, usando `DisposeAsync()` quando disponivel. + +## Generated Materialization Integration + +Todos os caminhos adicionados usam o mesmo dispatch: + +```text +entity + profile opcional + ordered column shape + -> generated materializer compativel + -> runtime NestedMaterializationPlan fallback +``` + +Generated materializers sao otimizacao, nao requisito funcional. Shapes +ausentes, reordenados, dinamicos ou nao suportados continuam usando fallback +runtime. + +## Resource Lifetime + +O FluentMap nao assume ownership da conexao recebida. Se o provider/Dapper abre +uma conexao fechada para o reader, o dispose do reader fecha essa conexao; se a +conexao ja estava aberta, ela permanece aberta. + +Streaming mantem reader/command/provider resources vivos durante a enumeracao. +O consumidor deve descartar enumeradores quando interromper consumo parcial; +`foreach` e `await foreach` fazem isso nos casos normais. + +## Cancellation + +Cancellation e parte do contrato apenas nas APIs async streaming. O token e +propagado para: + +- `CommandDefinition`; +- `DbDataReader.ReadAsync(token)`; +- verificacoes explicitas entre linhas. + +`OperationCanceledException` nao e convertida em erro de mapping. Suporte real +a cancelamento continua dependente do provider. + +## Historical Issues + +| Issue | Final status | Evidencia | +| --- | --- | --- | +| #22 conventions em multiple results | Regression covered; Resolved by implementation no caminho opt-in | `MappedConventionShouldApplyToTypedReadFromMultipleResults` cobre convencoes por entidade em multiplos result sets via `QueryMultipleMapped(...).ReadMapped*`. | +| #43 explicit map em result set posterior | Regression covered; Resolved by implementation no caminho opt-in; Already resolved previously no caminho Dapper historico conforme upstream | `ExplicitMapShouldApplyToLaterTypedReadFromMultipleResults` cobre terceiro grid com colunas equivalentes ao relato historico. | + +O caminho Dapper puro `connection.QueryMultiple(...).Read()` permanece +responsabilidade do Dapper/type map global. A Etapa 9 resolveu a lacuna para +materializacao avancada opt-in do FluentMap. + +## Performance + +Benchmark final local (`ShortRun`, 1000 linhas por operacao, SQLite em memoria, +.NET 10.0.10, Dapper 2.1.79): + +| Scenario | Allocated | +| --- | ---: | +| Dapper buffered | 283.17 KB | +| FluentMap buffered generated | 261.15 KB | +| FluentMap buffered runtime fallback | 361.58 KB | +| Dapper unbuffered | 266.96 KB | +| FluentMap unbuffered generated | 245.17 KB | +| FluentMap unbuffered runtime fallback | 345.48 KB | +| Dapper async unbuffered | 267.27 KB | +| FluentMap async unbuffered generated | 245.59 KB | +| FluentMap async unbuffered runtime fallback | 345.89 KB | +| Dapper QueryMultiple buffered | 284.18 KB | +| FluentMap QueryMultiple generated | 263.77 KB | +| FluentMap QueryMultiple runtime fallback | 363.07 KB | + +Interpretacao: + +- A evidencia mais estavel e de allocation, nao de throughput. +- Streaming evita a lista interna do FluentMap e materializa linha a linha. +- Generated path preserva a reducao de alocacao observada na Etapa 7. +- Tempo local em `ShortRun` e ruidoso demais para claims publicos. + +## Provider Coverage + +Cobertura executada: + +- Provider-independent: `DataTableReader` para grids deterministicos e multiple + result sets. +- SQLite: `Microsoft.Data.Sqlite` para ADO.NET real, connection lifetime, + transactions, sync/async readers e benchmarks. + +Nao houve infraestrutura proporcional para SQL Server ou PostgreSQL. Nao foram +adicionados pacotes, containers, variaveis de ambiente ou testes dependentes de +servicos externos no fechamento. + +## Backward Compatibility + +- Nenhuma API publica existente foi removida. +- `QueryMapped()` permaneceu buffered. +- APIs unbuffered receberam nomes explicitos em vez de alterar comportamento + existente. +- `QueryMultipleMapped` e aditivo e nao altera `SqlMapper.GridReader`. +- Profiles continuam query-scoped e nao alteram type maps globais. +- Dommel nao foi alterado nesta etapa. + +## Native AOT / Trimming Considerations + +As APIs `QueryMapped*`, `ReadMapped*`, `QueryMultipleMapped` e +`QueryMappedUnbuffered*` continuam anotadas com warnings de trimming/dynamic +code porque podem cair para fallback runtime baseado em reflection/dynamic code. + +Registro explicito e registro gerado seguem sendo os caminhos preferenciais +para apps com trimming. A Etapa 9 nao declara `QueryMapped*` como fully Native +AOT-safe. + +## Known Limitations + +- Result sets em `MappedGridReader` sao consumidos sequencialmente. +- Nao ha leitura concorrente no mesmo `MappedGridReader`. +- Streaming mantem reader aberto ate fim, early termination ou dispose. +- A mesma conexao nao deve ser usada concorrentemente enquanto reader estiver + ativo, salvo suporte explicito do provider. +- `QueryMultipleMapped` nao e Dapper multi-mapping por `splitOn`. +- Graph aggregation, identity map e automatic join grouping nao fazem parte do + FluentMap. +- APIs podem continuar usando runtime fallback mesmo quando generated + materializers existem para outros shapes. +- `ReadMappedUnbuffered*` e `QueryMultipleMappedAsync` foram adiados. +- SQL Server/PostgreSQL nao foram certificados nesta etapa. + +## Technical Debt + +- Formalizar API/binary compatibility checks antes de release maior. +- Avaliar diagnostics por query/column shape sem acoplar `Explain()` a SQL. +- Reavaliar ownership visivel de command se uma API async multiple result set + for criada. +- Criar matriz provider-specific apenas quando houver infraestrutura de CI ou + demanda real. +- Investigar caminho generated-only/AOT-safe em etapa propria. + +## Deferred Items + +- `QueryMultipleMappedAsync`. +- `ReadMappedUnbuffered()` / streaming por result set dentro de + `MappedGridReader`. +- Dapper multi-mapping por `splitOn`. +- Graph aggregation. +- Property converters. +- DI/configuration instances/scoped configuration. +- SQL generation, CRUD, LINQ e repository. +- Provider certification ampla para SQL Server/PostgreSQL. + +## Recommendations for Etapa 10 + +- Nao misturar converters/DI/configuration instances com hardening adicional de + QueryMultiple. +- Se houver demanda por `QueryMultipleMappedAsync`, projetar lifetime, + cancellation e async disposal antes de codificar. +- Se houver demanda por provider certification, criar harness opt-in e CI + antes de adicionar dependencias permanentes. +- Antes de ampliar generated-only/AOT, definir contrato que evite fallback + runtime ou documente explicitamente quando ele pode ocorrer. +- Manter o core focado em materializacao/mapping; nao iniciar CRUD, SQL + generation ou graph aggregation por acidente. + +## Validation + +Executado em 2026-07-28: + +```bash +dotnet restore ./Dapper.FluentMap.sln +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 --filter FullyQualifiedName~QueryMultipleMappedTests +dotnet test ./test/Dapper.FluentMap.Tests/Dapper.FluentMap.Tests.csproj --configuration Release --filter FullyQualifiedName~QueryMappedUnbufferedTests +dotnet test ./test/Dapper.FluentMap.Tests/Dapper.FluentMap.Tests.csproj --configuration Release --filter FullyQualifiedName~QueryMappedUnbufferedAsyncTests +dotnet test ./test/Dapper.FluentMap.Tests/Dapper.FluentMap.Tests.csproj --configuration Release --filter FullyQualifiedName~AdvancedQueryHardeningTests +dotnet run --project ./benchmarks/Dapper.FluentMap.Benchmarks/Dapper.FluentMap.Benchmarks.csproj --configuration Release --no-build -- --filter *MaterializationSteadyStateBenchmarks* +``` + +Resultados: + +- Restore: sucesso. +- Build Release: sucesso, 0 warnings, 0 errors. +- Solution tests: sucesso, 357 testes aprovados. +- `QueryMultipleMappedTests`: sucesso, 25 testes aprovados. +- `QueryMappedUnbufferedTests`: sucesso, 14 testes aprovados. +- `QueryMappedUnbufferedAsyncTests`: sucesso, 15 testes aprovados. +- `AdvancedQueryHardeningTests`: sucesso, 5 testes aprovados. +- Benchmarks steady state: sucesso, 18 cenarios executados. + +Observacao: duas primeiras tentativas de testes filtrados em paralelo falharam +com `CS2012` porque processos de build competiram pelo mesmo assembly em +`obj/Release`. As mesmas suites foram reexecutadas sequencialmente e passaram; +nao houve falha de teste. diff --git a/.sdd/etapa-9/STATUS.md b/.sdd/etapa-9/STATUS.md index 9fd4b6b..15685c0 100644 --- a/.sdd/etapa-9/STATUS.md +++ b/.sdd/etapa-9/STATUS.md @@ -1,5 +1,7 @@ # Etapa 9 Status +Status: Concluída + ## Objetivo Definir e evoluir a arquitetura de Advanced Query Materialization para @@ -122,6 +124,18 @@ equivalencia entre materializacao generated e runtime. `QueryMultipleMapped` em readers/conexoes independentes. - Prompt 9.6: adicionados benchmarks de `QueryMultiple` para Dapper buffered, FluentMap generated e FluentMap runtime fallback. +- Prompt 9.7: auditada a implementacao real contra + `.sdd/etapa-9/02-advanced-query-materialization-spec.md`. +- Prompt 9.7: revisadas APIs publicas introduzidas na Etapa 9; nenhuma + correcao pequena de API foi necessaria no fechamento. +- Prompt 9.7: README atualizado com exemplos reais de `QueryMultipleMapped`, + `ReadMapped`, profiles, streaming unbuffered, async streaming, cancellation, + generated/runtime dispatch e limitacoes. +- Prompt 9.7: `.sdd/etapa-9/01-historical-query-issues.md` atualizado com + classificacao final das issues #22 e #43. +- Prompt 9.7: `.sdd/etapa-9/06-performance-results.md` revisado com auditoria + final dos benchmarks representativos. +- Prompt 9.7: criado `.sdd/etapa-9/FINAL-REPORT.md`. ## Em andamento @@ -221,12 +235,41 @@ Nenhuma feature produtiva em andamento. - `dotnet test .\Dapper.FluentMap.sln --configuration Release --no-build`: sucesso, 357 testes aprovados no total. +## Validacao do Prompt 9.7 + +- `dotnet --version`: + `10.0.302`. +- Plataforma de testes detectada: VSTest com `Microsoft.NET.Test.Sdk` e xUnit + v3; nao ha `global.json`, `Directory.Build.props` ou + `Directory.Packages.props` com sinal de Microsoft.Testing.Platform. +- `dotnet test test\Dapper.FluentMap.Tests\Dapper.FluentMap.Tests.csproj --configuration Release --filter FullyQualifiedName~QueryMappedUnbufferedTests`: + sucesso, 14 testes aprovados. +- `dotnet test test\Dapper.FluentMap.Tests\Dapper.FluentMap.Tests.csproj --configuration Release --filter FullyQualifiedName~QueryMappedUnbufferedAsyncTests`: + sucesso, 15 testes aprovados. +- Tentativas paralelas iniciais de + `QueryMultipleMappedTests` e `AdvancedQueryHardeningTests` falharam com + `CS2012` por lock concorrente do mesmo assembly em `obj\Release`; nao foi + falha de teste. +- `dotnet test test\Dapper.FluentMap.Tests\Dapper.FluentMap.Tests.csproj --configuration Release --filter FullyQualifiedName~QueryMultipleMappedTests`: + sucesso apos reexecucao sequencial, 25 testes aprovados. +- `dotnet test test\Dapper.FluentMap.Tests\Dapper.FluentMap.Tests.csproj --configuration Release --filter FullyQualifiedName~AdvancedQueryHardeningTests`: + sucesso apos reexecucao sequencial, 5 testes aprovados. +- `dotnet restore .\Dapper.FluentMap.sln`: + sucesso. +- `dotnet build .\Dapper.FluentMap.sln --configuration Release --no-restore`: + sucesso, 0 warnings, 0 errors. +- `dotnet test .\Dapper.FluentMap.sln --configuration Release --no-build`: + sucesso, 357 testes aprovados no total. +- `dotnet run --project benchmarks\Dapper.FluentMap.Benchmarks\Dapper.FluentMap.Benchmarks.csproj --configuration Release --no-build -- --filter *MaterializationSteadyStateBenchmarks*`: + sucesso, 18 benchmarks executados. + ## Proximos passos -1. Executar documentacao final da Etapa 9, se solicitada. -2. Avaliar `QueryMultipleMappedAsync`/`ReadMappedUnbufferedAsync` em prompt +1. Avaliar `QueryMultipleMappedAsync`/`ReadMappedUnbufferedAsync` em prompt proprio, se houver demanda. -3. Avaliar caminho generated-only/AOT-safe em prompt proprio. +2. Avaliar caminho generated-only/AOT-safe em prompt proprio. +3. Criar provider certification opt-in somente quando houver infraestrutura de + CI ou demanda real para SQL Server/PostgreSQL. ## Decisoes relevantes @@ -336,7 +379,8 @@ await foreach (var customer in connection.QueryMappedUnbufferedAsync( - `test/Dapper.FluentMap.Tests/QueryMappedUnbufferedAsyncTests.cs` - `test/Dapper.FluentMap.Tests/AdvancedQueryHardeningTests.cs` - `benchmarks/Dapper.FluentMap.Benchmarks/Program.cs` +- `.sdd/etapa-9/FINAL-REPORT.md` -## Ultimo prompt executado +## Último prompt executado -Ultimo prompt executado: 9.6 +Último prompt executado: 9.7 diff --git a/README.md b/README.md index 282a403..0e8c085 100644 --- a/README.md +++ b/README.md @@ -441,7 +441,31 @@ var customers = multi.ReadMapped(); var orders = multi.ReadMapped(); ``` -`QueryMapped*` and `ReadMapped*` return buffered results and are the paths that support nested object materialization, constructor-built value objects and profile-specific mapping. +`QueryMapped*` and `ReadMapped*` return buffered results and are the paths that support nested object materialization, constructor-built value objects and profile-specific mapping. When a generated materializer is registered for the entity, profile and ordered column shape, these APIs use it; otherwise they use the runtime materializer fallback. + +Use `QueryMultipleMapped(...)` when one command returns multiple result sets that all need FluentMap-controlled materialization: + +```csharp +var sql = @" + SELECT 1 AS customer_id, 'Ada' AS customer_name; + SELECT 10 AS order_id, 42.50 AS total;"; + +using var multi = connection.QueryMultipleMapped(sql); + +var customers = multi.ReadMapped().ToList(); +var orders = multi.ReadMapped().ToList(); +``` + +Result sets are consumed sequentially. `ReadMapped()` and `ReadMapped()` buffer the current result set, advance to the next one and keep the underlying reader open until all result sets are consumed or the `MappedGridReader` is disposed. + +Profiles can be selected per result set: + +```csharp +using var multi = connection.QueryMultipleMapped(sql); + +var currentCustomers = multi.ReadMapped(); +var legacyCustomers = multi.ReadMapped(); +``` Use `QueryMappedUnbuffered()` or `QueryMappedUnbuffered()` when you need to process a large result set incrementally: @@ -457,16 +481,20 @@ Unbuffered queries are lazy: the command is executed when enumeration starts, no Use `QueryMappedUnbufferedAsync()` or `QueryMappedUnbufferedAsync()` on `DbConnection` when the provider supports asynchronous readers: ```csharp +using var cancellation = new CancellationTokenSource(); + await foreach (var customer in connection.QueryMappedUnbufferedAsync( sql, - cancellationToken)) + cancellation.Token)) { - await ProcessAsync(customer, cancellationToken); + await ProcessAsync(customer, cancellation.Token); } ``` Async unbuffered queries are also lazy and incremental. FluentMap awaits command execution and `DbDataReader.ReadAsync(...)`, propagates cancellation to supported async operations, and disposes the reader when enumeration completes, stops early, is canceled or throws. Row materialization remains synchronous after the row has been read; generated materializers and runtime fallback use the same dispatch as buffered and synchronous unbuffered queries. +`QueryMultipleMapped` is about multiple result sets, not Dapper multi-mapping with `splitOn`. FluentMap does not perform graph aggregation, identity maps or automatic join grouping; write the SQL shape you need and choose the mapped helper only when FluentMap should materialize each row. + ## Dommel Install `Dapper.FluentMap.Dommel` when using [Dommel](https://github.com/henkmollema/Dommel): @@ -542,6 +570,9 @@ persistence behavior that matches the intent: `ReadOnly()`, `Computed()`, - `QueryMapped*` may use generated materializers for supported flat, nested and Value Object shapes, but it can still fall back to runtime metadata and dynamic code; it is not yet a guaranteed Native AOT-safe materialization path. - Mapping profiles are selected through `QueryMapped()` and `ReadMapped()` APIs. - `QueryMapped*` and `ReadMapped*` are buffered. Use `QueryMappedUnbuffered*` for explicit synchronous or asynchronous unbuffered streaming. +- `QueryMultipleMapped` consumes result sets sequentially and does not support concurrent reads from the same `MappedGridReader`. +- Streaming keeps the underlying reader open. Do not use the same connection concurrently while a reader is active unless the provider explicitly supports that usage. +- Multiple result sets are not Dapper multi-mapping by `splitOn`; FluentMap does not perform graph aggregation or automatic join grouping. - Value object construction uses matching public constructors, not factory methods. ## Contributing @@ -1005,7 +1036,31 @@ var customers = multi.ReadMapped(); var orders = multi.ReadMapped(); ``` -`QueryMapped*` e `ReadMapped*` retornam resultados bufferizados e são os caminhos que suportam materialização de objetos aninhados, Value Objects construídos por construtor e mapeamento específico por profile. +`QueryMapped*` e `ReadMapped*` retornam resultados bufferizados e são os caminhos que suportam materialização de objetos aninhados, Value Objects construídos por construtor e mapeamento específico por profile. Quando existe materializador gerado para entidade, profile e shape ordenado de colunas, essas APIs o utilizam; caso contrário, usam o fallback de materialização em runtime. + +Use `QueryMultipleMapped(...)` quando um comando retorna múltiplos result sets que precisam de materialização controlada pelo FluentMap: + +```csharp +var sql = @" + SELECT 1 AS customer_id, 'Ada' AS customer_name; + SELECT 10 AS order_id, 42.50 AS total;"; + +using var multi = connection.QueryMultipleMapped(sql); + +var customers = multi.ReadMapped().ToList(); +var orders = multi.ReadMapped().ToList(); +``` + +Os result sets são consumidos sequencialmente. `ReadMapped()` e `ReadMapped()` bufferizam o result set atual, avançam para o próximo e mantêm o reader subjacente aberto até todos os result sets serem consumidos ou até o `MappedGridReader` ser descartado. + +Profiles podem ser selecionados por result set: + +```csharp +using var multi = connection.QueryMultipleMapped(sql); + +var currentCustomers = multi.ReadMapped(); +var legacyCustomers = multi.ReadMapped(); +``` Use `QueryMappedUnbuffered()` ou `QueryMappedUnbuffered()` quando precisar processar um result set grande de forma incremental: @@ -1021,16 +1076,20 @@ Consultas unbuffered são lazy: o comando é executado quando a enumeração com Use `QueryMappedUnbufferedAsync()` ou `QueryMappedUnbufferedAsync()` em `DbConnection` quando o provider suportar readers assíncronos: ```csharp +using var cancellation = new CancellationTokenSource(); + await foreach (var customer in connection.QueryMappedUnbufferedAsync( sql, - cancellationToken)) + cancellation.Token)) { - await ProcessAsync(customer, cancellationToken); + await ProcessAsync(customer, cancellation.Token); } ``` Consultas async unbuffered também são lazy e incrementais. O FluentMap aguarda a execução do comando e `DbDataReader.ReadAsync(...)`, propaga cancellation para operações async suportadas e descarta o reader quando a enumeração termina, para cedo, é cancelada ou falha. A materialização da linha continua síncrona depois que a linha foi lida; materializers gerados e fallback runtime usam o mesmo dispatch dos caminhos buffered e unbuffered síncrono. +`QueryMultipleMapped` trata de múltiplos result sets, não de Dapper multi-mapping com `splitOn`. O FluentMap não faz agregação de grafo, identity map nem agrupamento automático de joins; escreva o shape SQL necessário e use o helper mapeado apenas quando o FluentMap deve materializar cada linha. + ## Dommel Instale `Dapper.FluentMap.Dommel` ao usar [Dommel](https://github.com/henkmollema/Dommel): @@ -1107,6 +1166,9 @@ ainda devem ser lidos, use o persistence behavior correspondente: - `QueryMapped*` pode usar materializadores gerados para shapes flat, aninhados e Value Object suportados, mas ainda pode cair para metadados de runtime e código dinâmico; ele ainda não é um caminho de materialização garantidamente seguro para Native AOT. - Mapping profiles são selecionados pelas APIs `QueryMapped()` e `ReadMapped()`. - `QueryMapped*` e `ReadMapped*` são bufferizados. Use `QueryMappedUnbuffered*` para streaming unbuffered síncrono ou assíncrono explícito. +- `QueryMultipleMapped` consome result sets sequencialmente e não suporta leituras concorrentes do mesmo `MappedGridReader`. +- Streaming mantém o reader subjacente aberto. Não use a mesma conexão concorrentemente enquanto um reader estiver ativo, salvo quando o provider suportar explicitamente esse uso. +- Múltiplos result sets não são Dapper multi-mapping por `splitOn`; o FluentMap não faz agregação de grafo nem agrupamento automático de joins. - A construção de Value Objects usa construtores públicos compatíveis, não factory methods. ## Contribuição From 295ee1e4f0e591a1f1ac27b8a05abebac44c30db Mon Sep 17 00:00:00 2001 From: Rodrigo de Oliveira Date: Tue, 28 Jul 2026 16:37:14 -0300 Subject: [PATCH 22/49] docs(sdd): define property conversion architecture --- .sdd/etapa-10/01-conversion-landscape.md | 195 +++++++++++ .sdd/etapa-10/02-property-conversion-spec.md | 344 +++++++++++++++++++ .sdd/etapa-10/DECISIONS.md | 285 +++++++++++++++ .sdd/etapa-10/STATUS.md | 184 ++++++++++ 4 files changed, 1008 insertions(+) create mode 100644 .sdd/etapa-10/01-conversion-landscape.md create mode 100644 .sdd/etapa-10/02-property-conversion-spec.md create mode 100644 .sdd/etapa-10/DECISIONS.md create mode 100644 .sdd/etapa-10/STATUS.md diff --git a/.sdd/etapa-10/01-conversion-landscape.md b/.sdd/etapa-10/01-conversion-landscape.md new file mode 100644 index 0000000..dfb97f8 --- /dev/null +++ b/.sdd/etapa-10/01-conversion-landscape.md @@ -0,0 +1,195 @@ +# Etapa 10 - Conversion Landscape + +## Discovery local + +O FluentMap atual possui duas rotas de leitura com responsabilidades diferentes: + +- `connection.Query()` e APIs normais do Dapper usam `SqlMapper.SetTypeMap(...)` + instalado por `FluentMapTypeMap`. O FluentMap resolve nomes de colunas, + propriedades e parametros de construtor raiz; a conversao de valores continua + sendo do Dapper e do provider ADO.NET. +- `QueryMapped*`, `ReadMapped*`, `QueryMultipleMapped` e streaming usam + `IDataRecord` diretamente via `MappedRowMaterializer`. Nesse caminho o + FluentMap controla materializacao flat, nested, Value Objects e profiles. + +Pontos de conversao existentes: + +- Runtime materializer: `NestedMaterializationPlan.CreateConverter(...)` usa + `DapperTypeHandlerAdapter` quando ha `SqlMapper.TypeHandler` para o tipo + de destino; caso contrario aplica null/default, cast direto, enum, + `Guid` de string e `Convert.ChangeType(..., InvariantCulture)`. +- Generated materializer: `MappingRegistrationGenerator.AppendReadHelper(...)` + emite null/default, cast direto, enum, `Guid` de string e + `Convert.ChangeType(...)`. Ele nao consulta `TypeHandler`. +- Constructor mapping runtime: valores de folhas sao convertidos antes de + entrar nos argumentos do construtor. +- Nested objects: a arvore aninhada e criada somente quando algum valor da + subarvore nao e `DBNull`; conversao ocorre nas folhas terminais. +- Value Objects por componentes: sao materializados por construtores publicos + compativeis, com conversao nas folhas componentes. +- Value Objects escalares: hoje devem preferir `TypeHandler` do Dapper, e ha + teste cobrindo `QueryMappedShouldUseDapperTypeHandlerForScalarValueObjectProperty`. +- Persistence metadata: descreve participacao em leitura/insert/update, mas nao + converte valores. +- Dommel: a integracao atual controla resolucao de propriedades, colunas, + chaves e SQL de insert/update por metadata. Ela nao transforma valores de + parametros por propriedade. + +Responsabilidades atuais: + +```text +ADO.NET provider + entrega valores CLR em IDataRecord.GetValue e aplica DbParameter.Value + +Dapper + converte no caminho Query, usa TypeHandler global por tipo e cria + parametros em Execute/operacoes usadas por Dommel + +FluentMap + resolve membros/colunas e, nos caminhos QueryMapped*, converte valores + lidos para propriedades/construtores + +Application converter + ainda nao existe como contrato FluentMap por propriedade/profile +``` + +## Dapper TypeHandler + +Fonte: https://github.com/DapperLib/Dapper/blob/main/Dapper/SqlMapper.TypeHandler.cs + +### Escopo + +`SqlMapper.TypeHandler` e um mecanismo global por tipo CLR registrado no +Dapper. O contrato possui duas direcoes: + +- `Parse(object value)` para transformar um valor vindo do banco em `T`; +- `SetValue(IDbDataParameter parameter, T value)` para configurar parametros. + +Ele e excelente quando a representacao de um tipo e uniforme em toda a +aplicacao, por exemplo `Cpf` sempre vindo de uma coluna `VARCHAR`. + +### Pontos fortes + +- Integracao nativa com Dapper. +- Um unico registro atende leitura e parametros onde o Dapper invoca handlers. +- Bom para Value Objects escalares com representacao global. +- Nao exige que FluentMap replique conversao por tipo. + +### Limitacoes para FluentMap + +- O escopo e global por tipo, nao por propriedade, entity map ou profile. +- Duas propriedades do mesmo tipo nao conseguem usar representacoes diferentes. +- O contrato nao sabe qual member path, entidade, profile ou coluna esta sendo + convertida. +- No FluentMap atual, o generated materializer nao consulta `TypeHandler`; + portanto runtime e generated podem divergir em Value Objects escalares. +- Para escrita via Dommel, `TypeHandler` continua util, mas nao resolve o + caso property-scoped quando duas propriedades do mesmo tipo exigem formatos + diferentes. + +## RepoDB PropertyHandler + +Fontes: + +- https://repodb.net/feature/propertyhandlers +- https://repodb.net/interface/ipropertyhandler +- https://repodb.net/reference/propertyhandlerpropertylevel +- https://repodb.net/reference/propertyhandlertypelevel + +### Escopo + +RepoDB possui `IPropertyHandler` para transformacao entre tipo +de coluna e tipo da propriedade. A documentacao separa uso property-level e +type-level. O handler recebe valor e contexto da propriedade, e possui duas +operacoes conceituais: + +- `Get(...)` em leitura/hidratacao; +- `Set(...)` antes de escrita. + +### Pontos fortes + +- Resolve diretamente o problema de conversao por propriedade. +- Permite inbound/outbound e contexto da propriedade. +- Pode ser aplicado por atributo ou via fluent mapping. +- A semantica de propriedade tem menos surpresa que um handler global por tipo. + +### Limitacoes para FluentMap + +- RepoDB e uma biblioteca com CRUD/SQL generation mais amplo; FluentMap nao deve + importar esse escopo. +- A API de contexto e lifecycle do RepoDB nao se transfere diretamente para + Dapper/FluentMap. +- O FluentMap deve preservar modelos sem atributos como caminho principal. +- O uso type-level do RepoDB se aproxima de `TypeHandler`; no FluentMap isso + deve continuar pertencendo ao Dapper, salvo configuracoes explicitamente + property/profile-scoped. + +## EF Core ValueConverter + +Fontes: + +- https://learn.microsoft.com/en-us/ef/core/modeling/value-conversions +- https://github.com/dotnet/efcore/blob/main/src/EFCore/Storage/ValueConversion/ValueConverter.cs + +### Escopo + +EF Core `ValueConverter` converte entre `ModelClrType` e `ProviderClrType`. +O modelo e definido por propriedade no metadata do EF. O contrato usa expressoes +para: + +- converter do modelo para o provider em escrita; +- converter do provider para o modelo em leitura. + +EF tambem modela hints, composicao e comportamento de nulls. + +### Pontos fortes + +- Direcoes explicitas. +- Tipos de modelo/provider claros. +- Integra bem com geracao/compilacao de delegates. +- A semantica de null e parte do contrato. +- O design e reconhecido por usuarios .NET para DDD Value Objects e enums. + +### Limitacoes para FluentMap + +- EF Core e um ORM completo; FluentMap nao deve adotar conceitos de tracking, + model builder, migrations, comparers ou schema facets. +- Expression trees sao boas para composicao EF, mas podem aumentar superficie + publica, trimming e complexidade no FluentMap. +- Converter bidirecional obrigatorio seria restritivo para cenarios read-only ou + write-only. +- Converter de provider/model nao deve virar um serializer framework generico. + +## Outros sinais relevantes + +Dommel usa Dapper para execucao e mapping e expoe extensibility para nomes de +tabela, colunas, chaves, propriedades e SQL builder. A integracao FluentMap +atual aproveita esses pontos para metadata de persistencia, mas nao possui um +hook local por propriedade para alterar `DbParameter.Value`. + +O historico de issues de Dapper/Dommel mostra que handlers globais podem ser +confusos em bibliotecas que geram SQL/parametros. A Etapa 10 deve evitar uma +promessa de escrita property-scoped antes de haver uma integracao testada com a +geracao de parametros. + +## Conclusao para FluentMap + +FluentMap deve adicionar property conversion como metadata do mapping, nao como +outro registry global por tipo. A fronteira recomendada e: + +```text +Property/profile converter configurado explicitamente + cobre a direcao configurada para aquele member path + +Dapper TypeHandler + continua sendo o mecanismo global por tipo e fallback preferido para + Value Objects escalares sem conversor por propriedade + +Conversao padrao do FluentMap/Dapper/provider + preserva comportamento atual quando nao ha converter +``` + +O primeiro incremento deve focar metadata/contracts e read conversion no +`QueryMapped*` runtime. Generated read conversion, profile hardening e write +conversion/Dommel devem entrar em incrementos separados para preservar +compatibilidade e testar equivalencia. diff --git a/.sdd/etapa-10/02-property-conversion-spec.md b/.sdd/etapa-10/02-property-conversion-spec.md new file mode 100644 index 0000000..3e7c513 --- /dev/null +++ b/.sdd/etapa-10/02-property-conversion-spec.md @@ -0,0 +1,344 @@ +# Etapa 10 - Property Conversion Specification + +## Objetivos + +- Permitir conversao configuravel por propriedade/member path. +- Permitir conversao especifica por mapping profile. +- Modelar leitura e escrita como direcoes independentes. +- Preservar `TypeHandler` do Dapper como mecanismo global por tipo. +- Manter compatibilidade publica e comportamento atual quando nenhum converter + estiver configurado. +- Suportar runtime materialization primeiro e generated materialization depois, + com equivalencia testavel. +- Preparar metadata suficiente para Dommel/write sem prometer escrita antes do + hook de parametros estar validado. + +## Nao objetivos + +- Serializer framework generico. +- Object mapper geral. +- Substituto de AutoMapper. +- SQL generator no core. +- Query builder. +- ORM. +- Repository. +- Unit of Work. +- Change tracking. +- Schema conversion. +- Migrations. +- DI container obrigatorio. +- Substituir `SqlMapper.TypeHandler`. + +## Read conversion + +Read conversion transforma o valor do banco/provider para o tipo da propriedade +ou parametro de construtor: + +```text +Database/provider CLR value -> Property CLR value +``` + +No `QueryMapped*`, o valor de entrada e o resultado de `IDataRecord.GetValue`. +O converter nao deve receber a linha inteira por padrao; isso preserva o foco em +property conversion e evita graph aggregation. + +Precedencia proposta para `QueryMapped*`: + +```text +DBNull/null handling padrao, salvo opt-in explicito de converter de null + -> property read converter, se configurado + -> Dapper TypeHandler, se existente e aplicavel + -> conversao padrao atual do FluentMap +``` + +Para `connection.Query()`, o FluentMap nao controla read conversion +property-scoped. Essa rota continua sendo Dapper + `TypeHandler` + provider. + +## Write conversion + +Write conversion transforma o valor de propriedade para o valor de parametro: + +```text +Property CLR value -> Database/provider CLR value +``` + +Ela deve ser independente de read conversion. Um mapping pode ter apenas read +converter, apenas write converter, ou ambos. + +Primeira regra de design: o core pode guardar metadata de escrita, mas a +execucao inicial deve ser feita somente em integracoes com ponto real de +parametrizacao testado. Dommel exige investigacao/implementacao propria porque +a integracao atual controla colunas e SQL builders, mas nao transforma valores +de parametros por propriedade. + +## Property-scoped conversion + +Um converter configurado em `Map(x => x.Status)` pertence ao member path +efetivo daquele property map. Ele nao se aplica a outras propriedades do mesmo +tipo. + +Exemplo conceitual: + +```csharp +Map(x => x.LegacyStatus) + .ToColumn("legacy_status") + .ConvertFromDatabaseUsing() + .ConvertToDatabaseUsing(); + +Map(x => x.CurrentStatus) + .ToColumn("status") + .ConvertFromDatabaseUsing(); +``` + +O tipo de banco/provider (`string`, `int`, etc.) deve ser parte do contrato para +evitar conversores baseados em `object` em APIs principais. + +## Profile-scoped conversion + +Profiles ja sao maps separados para a mesma entidade sob shapes SQL diferentes. +Um converter configurado em um map que implementa `IProfileMap` deve +ser valido somente para aquele profile. + +Precedencia por profile: + +```text +profile property converter + -> profile property mapping sem converter + -> default entity map/convention somente quando a operacao nao seleciona profile +``` + +Nao deve haver vazamento automatico de converter do default map para profile. +Se reutilizacao for desejada, ela deve ser explicita via `IncludeBase()` ou +API futura bem definida. + +## Global TypeHandler interoperability + +`TypeHandler` permanece o mecanismo recomendado quando todo o tipo `T` tem a +mesma representacao no banco. + +Um property converter configurado explicitamente deve ter precedencia sobre +`TypeHandler` somente na direcao configurada e somente no caminho controlado +pelo FluentMap. + +Sem converter: + +- runtime `QueryMapped*` deve continuar consultando `TypeHandler` antes da + conversao padrao; +- generated materialization deve ganhar uma decisao explicita: ou emite chamada + segura ao contrato publico de conversao do FluentMap, ou recusa generated + materializer para tipos que dependem de `TypeHandler`. + +## Null semantics + +Default recomendado: + +- `DBNull` e `null` nao sao enviados ao converter. +- propriedades nullable/reference recebem `null`; +- propriedades value type nao nullable recebem `default(T)`, preservando o + comportamento atual. + +Deve existir uma decisao futura, talvez `ConvertsNulls`, somente se houver caso +real. Converter de null aumenta risco de comportamento divergente entre +runtime/generated e pode quebrar suposicoes de nested subtree null. + +## Nullable + +O matching de tipos deve considerar `Nullable` e `T` como compativeis para +selecao de converter, mas a semantica de null deve continuar externa ao +converter por default. + +Um read converter para `TDatabase -> TProperty` pode alimentar `TProperty?` +quando `TProperty` for value type, desde que o resultado seja atribuivel. + +## Value objects + +Dois cenarios devem permanecer distintos: + +- Value Object escalar mapeado como propriedade inteira: + `Map(x => x.Cpf).ToColumn("cpf")`. Use `TypeHandler` quando a + representacao for global; use property converter quando a representacao + variar por propriedade/profile. +- Value Object por componentes: + `Map(x => x.Cpf.Number).ToColumn("cpf")`. O converter se aplica ao componente + terminal, nao ao Value Object inteiro, salvo API futura explicita para + converter subarvore completa. + +Factory methods continuam fora do escopo da Etapa 10. Conversores nao devem ser +usados para contornar uma materializacao de Value Object aninhado sem design. + +## Nested mappings + +Converters se aplicam a folhas terminais (`Address.City`, `Rank.Level`), nao a +objetos intermediarios. A criacao/null de subarvores aninhadas continua regida +por `HasNonNullValue` nos ordinais da subarvore. + +Se todos os valores de uma subarvore sao `DBNull`, o converter de folha nao e +executado por default. + +## Constructor mapping + +Runtime constructor mapping deve converter folhas antes de montar os argumentos +do construtor. + +Falhas de converter em argumentos de construtor devem ser encapsuladas com +contexto de entity type, member path, coluna e construtor, preservando a inner +exception. + +Constructor matching nao deve depender do tipo de banco do converter. Ele deve +continuar usando o tipo da propriedade/member path para determinar se o +construtor e compativel. + +## Generated materialization + +Generated materializers so devem ser emitidos quando a cadeia de conversao for +deterministica e referenciavel em codigo gerado. + +Requisitos: + +- o descriptor gerado deve incluir metadata de conversao suficiente para validar + que o materializer ainda corresponde ao mapping efetivo; +- conversores por tipo sem construtor publico ou instancia conhecida devem + causar fallback runtime, nao codigo quebrado; +- runtime e generated devem compartilhar a mesma semantica de null, enum, + `Guid`, `TypeHandler` e property converter. + +Direcao preferida para evitar duplicacao: extrair um runtime helper publico ou +internal-with-generator-contract para leitura de valor que o generated code +possa chamar. + +## Runtime materialization + +O runtime deve anexar `PropertyConversionMetadata` a cada leaf durante a criacao +do `NestedMaterializationPlan`. + +A chave de cache de materializacao hoje e `entity + profile + ordered columns`. +Como os caches sao invalidados ao registrar maps/conventions, nao e necessario +incluir converter na chave se metadata e imutavel depois de registrado. Se a +API permitir mutacao posterior, o cache precisara incluir versao/configuracao +ou impedir mutacao depois do registro. + +## Persistence / Dommel integration + +Property write conversion deve ser exposta como metadata no core, mas executada +somente onde ha controle de parametro. + +Para Dommel, a implementacao deve responder antes de codificar: + +- Dommel 3.5.3 permite interceptar valor de parametro por propriedade via API + publica? +- Se nao permite, o FluentMap deve criar wrapper de parametro, `DynamicParameters` + ou caminho proprio de SQL? +- Como preservar builders customizados registrados depois de `ForDommel()`? +- Como garantir que `TypeHandler` global ainda seja usado quando nao ha + write converter? + +Sem essas respostas, a Etapa 10 nao deve declarar write conversion completa. + +## Error behavior + +Erros devem ser diagnosticos, deterministas e preservar inner exception. + +Read conversion deve falhar com `FluentMapConfigurationException` quando: + +- converter nao implementa a direcao requerida; +- tipo de entrada/saida e incompativel com coluna/propriedade; +- instancia do converter nao pode ser criada por API configurada; +- conversao lanca excecao. + +Mensagens devem incluir entidade, profile quando houver, member path, coluna, +direcao e converter type. + +## Diagnostics + +`Explain()` e `Explain()` devem expor conversao de +forma aditiva, por exemplo: + +- read converter type; +- write converter type; +- provider/database CLR type declarado; +- null handling; +- source: explicit/profile/inherited/convention quando aplicavel. + +Analyzers devem evoluir gradualmente: + +- reconhecer fluent chains com `Convert...`; +- detectar converter sem contrato esperado; +- detectar uso de API unsupported pelo generator; +- alertar quando generated materializer fara fallback por converter nao + estaticamente suportado, se esse diagnostico for util. + +## Trimming + +APIs baseadas em `ConvertUsing()` com ativacao por reflection exigem +anotacoes de trimming para construtor publico. APIs por instancia ou delegate +sao mais amigaveis para trimming. + +Direcao recomendada: + +- oferecer overload por instancia/factory como caminho AOT-friendly; +- permitir overload generico com constraints e anotacoes claras; +- documentar que assembly scanning continua sensivel a trimming. + +## Native AOT + +Native AOT nao deve depender de `Expression.Compile()` ou reflection tardia para +conversao generated. + +Estrategia: + +- runtime fallback permanece anotado como trimming/dynamic-code sensitive; +- generated materializer pode ser AOT-friendly somente quando todos os + conversores sao referenciaveis estaticamente e nao exigem ativacao dinamica; +- `TypeHandler` interop no generated path precisa evitar reflexao sobre + internals do Dapper, ou cair para runtime fallback. + +## Thread safety + +Converters devem ser tratados como stateless e thread-safe por contrato. +Instancias podem ser reutilizadas entre materializacoes concorrentes. + +Se a API aceitar instancias stateful, a documentacao deve dizer que o usuario e +responsavel por thread safety. O FluentMap nao deve criar escopos por query na +primeira versao. + +## Converter lifetime + +Decisao proposta para o primeiro incremento: + +- converter type com construtor publico parameterless: uma instancia por + property map registrado; +- converter instance fornecida pelo usuario: a propria instancia e reutilizada; +- sem DI container no core; +- factory/DI fica adiado como extensibilidade futura. + +Isso reduz allocations e mantem startup/configuration como ponto de validacao. + +## Backward compatibility + +Sem converter configurado: + +- Dapper `Query()` deve se comportar exatamente como hoje; +- `QueryMapped*` runtime deve preservar null/default, enum, `Guid`, + `Convert.ChangeType` e `TypeHandler`; +- generated materializers atuais devem continuar validos ou cair para runtime + fallback quando nova metadata tornar o descriptor insuficiente; +- `IPropertyMap` nao deve ser quebrada. Metadata nova deve vir por interface + aditiva. + +## Performance + +O custo por linha deve ser proximo de uma chamada de delegate apos o plano ser +criado. Resolucao de converter, validacao de tipos e criacao de instancias deve +acontecer no registro ou na criacao do plano, nao em cada valor. + +Generated materialization deve conseguir inline/chamar helpers sem alocacoes por +coluna. Delegates por leaf sao aceitaveis no runtime fallback. + +Benchmarks devem comparar: + +- sem converter; +- converter simples read; +- converter simples write quando houver Dommel; +- TypeHandler global; +- generated vs runtime fallback; +- nested/value object/profile. diff --git a/.sdd/etapa-10/DECISIONS.md b/.sdd/etapa-10/DECISIONS.md new file mode 100644 index 0000000..5a4907b --- /dev/null +++ b/.sdd/etapa-10/DECISIONS.md @@ -0,0 +1,285 @@ +# Etapa 10 - Architectural Decisions + +## ADR-1 - Property converter vs Dapper TypeHandler + +### Contexto + +Dapper ja possui `SqlMapper.TypeHandler` com leitura e escrita globais por +tipo. A lacuna do FluentMap e permitir representacoes diferentes para +propriedades/profile do mesmo tipo. + +### Decisao + +Property converters serao metadata de mapping por member path/profile. Eles nao +substituem `TypeHandler` e nao serao registrados globalmente por tipo. + +### Alternativas consideradas + +- Criar um registry global `Status -> converter`: rejeitado por duplicar + `TypeHandler`. +- Usar apenas `TypeHandler`: insuficiente para propriedades do mesmo tipo com + representacoes diferentes. +- Adotar modelo amplo de ORM: fora do escopo do FluentMap. + +### Consequencias + +Consumidores continuam usando `TypeHandler` para representacao uniforme. +Property converters ficam reservados para variacao local e podem ser explicados +em diagnostics por propriedade/profile. + +## ADR-2 - Read vs write conversion + +### Contexto + +Nem todo converter e bidirecional. Um sistema pode ler legado sem escrever no +mesmo formato, ou escrever representacao customizada sem participar de +materializacao avancada. + +### Decisao + +Leitura e escrita serao modeladas como direcoes independentes. Um mapping pode +configurar somente read, somente write ou ambos. + +### Alternativas consideradas + +- Converter bidirecional obrigatorio: simples, mas forca implementacoes falsas. +- Delegates livres sem direcao: pouco discoverable e fraco para diagnostics. + +### Consequencias + +A API precisa ter nomes claros para cada direcao. Validacao deve falhar somente +quando uma operacao tenta usar uma direcao ausente. + +## ADR-3 - Converter contract + +### Contexto + +O contrato precisa ser type-safe, eficiente, diagnosticavel e viavel para +source generation/AOT. + +### Decisao + +Modelo preferido: + +```csharp +public interface IReadPropertyConverter +{ + TProperty ConvertFromDatabase(TDatabase value); +} + +public interface IWritePropertyConverter +{ + TDatabase ConvertToDatabase(TProperty value); +} + +public interface IPropertyConverter : + IReadPropertyConverter, + IWritePropertyConverter +{ +} +``` + +Delegates podem existir como overload ergonomico, mas o metadata canonico deve +ser descritor tipado com direcao. + +### Alternativas consideradas + +- `IPropertyConverter` unico: ambiguo em APIs + bidirecionais. +- `object Convert(object value, context)`: flexivel, mas perde type safety e + piora generated/AOT. +- Expression trees estilo EF: poderosas, mas mais pesadas que o FluentMap + precisa nesta etapa. + +### Consequencias + +APIs ficam mais verbosas, porem claras. O generator consegue identificar +contratos e tipos. Contexto extra fica adiado para uma versao futura. + +## ADR-4 - Converter lifetime + +### Contexto + +FluentMap e configurado globalmente no startup e depois deve ser tratado como +read-only. Converters podem ser usados em consultas concorrentes. + +### Decisao + +Converters sao stateless/thread-safe por contrato. Instancias genericas sao +criadas no registro/plano e reutilizadas. Overloads por instancia reutilizam a +instancia fornecida pelo usuario. + +### Alternativas consideradas + +- Nova instancia por linha: rejeitado por custo. +- DI scoped por query: adiado; introduz escopo/configuration instances. +- Singleton global por tipo de converter: possivel, mas uma instancia por + property map simplifica futuras configuracoes locais. + +### Consequencias + +Conversores stateful sao responsabilidade do consumidor. O core nao ganha +dependencia de DI. + +## ADR-5 - Precedence + +### Contexto + +O comportamento atual ja possui explicit mapping, convention e Dapper default +para nomes; e TypeHandler/global/default para valores no runtime mapped. + +### Decisao + +Para `QueryMapped*`: + +```text +property read converter + -> Dapper TypeHandler + -> FluentMap default conversion +``` + +Null/`DBNull` continuam tratados antes do converter por default. + +Para escrita futura: + +```text +property write converter + -> Dapper TypeHandler + -> Dapper/provider parameter default +``` + +Somente a direcao configurada ganha precedencia. + +### Alternativas consideradas + +- TypeHandler antes do property converter: surpreendente, pois um mapping + explicito local nao teria efeito. +- Property converter global por tipo antes de TypeHandler: rejeitado por duplicar + Dapper. + +### Consequencias + +Converter local tem prioridade previsivel, mas nao altera Dapper puro nem outras +propriedades do mesmo tipo. + +## ADR-6 - Profile-scoped converters + +### Contexto + +Profiles ja representam shapes SQL alternativos para a mesma entidade. + +### Decisao + +Conversores configurados em `IProfileMap` aplicam somente aquele +profile. Nao ha heranca automatica de converters do default map para profiles. + +### Alternativas consideradas + +- Converter default herdado por profile: reduz repeticao, mas cria surpresa em + profiles legados. +- Registry separado por profile: adiado ate haver necessidade real alem dos + property maps de profile. + +### Consequencias + +Cada profile declara sua representacao explicitamente. Reuso depende de base +maps/inclusao ou API futura. + +## ADR-7 - Runtime vs generated execution + +### Contexto + +Etapa 9 consolidou dispatch generated-then-runtime por shape. O generated +materializer atual replica conversao default e nao usa TypeHandler. + +### Decisao + +Runtime sera o primeiro executor suportado para read converters. Generated +materialization so deve emitir converter quando conseguir validar metadata e +referenciar o conversor de forma deterministica; caso contrario deve cair para +runtime fallback. + +### Alternativas consideradas + +- Implementar runtime e generated juntos: alto risco de divergencia. +- Sempre desabilitar generated quando houver converter: seguro, mas perde + beneficio em casos simples e AOT-friendly. +- Fazer generator instanciar qualquer converter por reflection: rejeitado para + AOT/trimming. + +### Consequencias + +O plano incremental precisa de testes de equivalencia antes de declarar suporte +generated completo. + +## ADR-8 - Dommel/write integration + +### Contexto + +Dommel gera SQL e parametros usando Dapper. A integracao atual do FluentMap +filtra propriedades/colunas, mas nao transforma valores por propriedade. + +### Decisao + +Write conversion fica especificada e descrita em metadata no core, mas a +execucao Dommel sera incremento separado. Nao declarar suporte completo ate +validar um hook de parametro por propriedade. + +### Alternativas consideradas + +- Confiar em `TypeHandler` para escrita: resolve apenas global por tipo. +- Gerar SQL proprio no core: fora do escopo. +- Reescrever Dommel: fora do escopo. + +### Consequencias + +Etapa 10 deve separar read conversion de write conversion. O risco de Dommel +fica visivel e testavel. + +## ADR-9 - Error semantics + +### Contexto + +Conversores de aplicacao podem falhar por dados invalidos, configuracao +incompativel ou regras de dominio. + +### Decisao + +Falhas de configuracao e execucao de converter no caminho FluentMap devem virar +`FluentMapConfigurationException` com inner exception preservada e contexto de +entity/profile/member/column/direction/converter. + +### Alternativas consideradas + +- Propagar excecao original sem contexto: diagnostico ruim. +- Criar nova hierarquia publica de excecoes: adiado; aumenta superficie. + +### Consequencias + +Mensagens devem ser uteis, mas testes nao devem depender de texto completo sem +necessidade. + +## ADR-10 - Native AOT strategy + +### Contexto + +O projeto ja diferencia registro explicito/generated de reflection scanning e +mantem `QueryMapped*` anotado por fallback runtime. + +### Decisao + +Property converters nao tornam `QueryMapped*` AOT-safe por si. O caminho +AOT-friendly deve exigir converter instance/delegate ou tipo estaticamente +referenciavel no generated materializer. Ativacao por reflection deve ser +anotada ou causar fallback. + +### Alternativas consideradas + +- Declarar AOT-safe apos compilar: rejeitado. +- Exigir somente delegates: bom para AOT, mas pior discoverability e analyzer. +- Exigir somente converter type: simples, mas mais trimming-sensitive. + +### Consequencias + +Documentacao deve separar "supported at runtime" de "generated/AOT-friendly". +Smoke AOT deve entrar somente quando houver implementacao generated. diff --git a/.sdd/etapa-10/STATUS.md b/.sdd/etapa-10/STATUS.md new file mode 100644 index 0000000..ab06992 --- /dev/null +++ b/.sdd/etapa-10/STATUS.md @@ -0,0 +1,184 @@ +# Etapa 10 Status + +## Objetivo + +Definir discovery, boundaries e arquitetura inicial para Property Conversion & +Extensibility, preservando `TypeHandler` do Dapper como mecanismo global por +tipo e abrindo espaco para conversao por propriedade, map e profile. + +## Concluido + +- Executado `git status` antes de alteracoes. +- Confirmada branch `feature/etapa-3`; nao estamos em `master`. +- Identificado item nao rastreado preexistente `src/Dapper.FluentMap/etapas/`, + deixado intacto. +- Lido `README.md`. +- Examinada `Dapper.FluentMap.sln`. +- Examinados projetos core, Dommel, analyzers, generators, testes, smoke AOT e + benchmarks por arquivos de projeto e pontos de implementacao relevantes. +- Examinado runtime materialization em `NestedMaterializationPlan` e + `MappedRowMaterializer`. +- Examinado generated materialization em `GeneratedMaterializerDescriptor`, + `GeneratedMaterializerColumn` e `MappingRegistrationGenerator`. +- Examinado source generator, incluindo fluent-chain parsing e helper `Read`. +- Examinado analyzer, incluindo fluent-chain parsing e metadata de persistencia. +- Examinada persistence metadata em `PropertyPersistenceMetadata`. +- Examinada integracao Dommel em property/key/column/table resolvers e SQL + builder de persistencia. +- Lidos `.sdd/etapa-9/FINAL-REPORT.md` e `.sdd/etapa-9/STATUS.md`. +- Consultado `.sdd/etapa-8/FINAL-REPORT.md`. +- Confirmado que `.sdd/etapa-10/` nao existia e criada a pasta. +- Consultadas fontes de ecossistema: Dapper TypeHandler, RepoDB PropertyHandler + e EF Core ValueConverter. +- Criado `.sdd/etapa-10/01-conversion-landscape.md`. +- Criado `.sdd/etapa-10/02-property-conversion-spec.md`. +- Criado `.sdd/etapa-10/DECISIONS.md`. +- Criado `.sdd/etapa-10/STATUS.md`. + +## Em andamento + +Nenhuma feature produtiva em andamento. Esta passada e SDD/arquitetura. + +## Proximos passos + +1. Implementar metadata/contracts aditivos de conversao sem alterar + comportamento. +2. Adicionar read conversion no runtime materializer com testes de regressao. +3. Evoluir generated read conversion ou fallback seguro quando houver converter. +4. Cobrir profile, inherited maps, nested leaves e Value Objects. +5. Investigar e implementar write conversion/Dommel somente apos definir hook + de parametros por propriedade. +6. Evoluir diagnostics/analyzers. +7. Medir performance e documentar API publica. + +## Decisoes relevantes + +- Property converter nao substitui `TypeHandler`. +- Read e write conversion sao direcoes independentes. +- Converter local tem precedencia sobre `TypeHandler` apenas na direcao e + propriedade/profile configurados. +- `TypeHandler` continua recomendado para Value Objects escalares com + representacao global uniforme. +- Runtime read conversion deve vir antes de generated read conversion. +- Generated materializer deve cair para runtime fallback quando converter nao + puder ser emitido com seguranca. +- Dommel/write conversion e incremento separado porque a integracao atual nao + transforma valores de parametros por propriedade. +- Converters sao stateless/thread-safe por contrato e reutilizados. +- AOT exige caminho por instancia/delegate ou referencia estatica gerada; nao + deve depender de ativacao reflection-only. + +## APIs propostas + +APIs conceituais, ainda nao implementadas: + +```csharp +Map(x => x.Status) + .ToColumn("status") + .ConvertFromDatabaseUsing(); +``` + +```csharp +Map(x => x.Status) + .ToColumn("status") + .ConvertToDatabaseUsing(); +``` + +```csharp +Map(x => x.Status) + .ToColumn("status") + .ConvertUsing(); +``` + +Contratos conceituais: + +```csharp +public interface IReadPropertyConverter +{ + TProperty ConvertFromDatabase(TDatabase value); +} + +public interface IWritePropertyConverter +{ + TDatabase ConvertToDatabase(TProperty value); +} + +public interface IPropertyConverter : + IReadPropertyConverter, + IWritePropertyConverter +{ +} +``` + +## Riscos conhecidos + +- Runtime e generated ja possuem conversao duplicada; TypeHandler funciona no + runtime mapped, mas nao no helper gerado atual. +- `DapperTypeHandlerAdapter` consulta detalhe interno `SqlMapper.TypeHandlerCache.Parse(object)`; + isso e uma fronteira de compatibilidade sensivel. +- Write conversion por propriedade nao e trivial em Dommel porque o hook atual + controla colunas/SQL, nao `DbParameter.Value`. +- Null conversion opt-in pode quebrar semantica de subarvores aninhadas se for + introduzida cedo demais. +- Converter por reflection precisa de anotacoes de trimming e estrategia AOT. +- Caches atuais assumem configuracao efetivamente imutavel apos registro. + +## Validacao do Prompt 10.1 + +- `dotnet restore ./Dapper.FluentMap.sln`: sucesso. +- `dotnet build ./Dapper.FluentMap.sln --configuration Release --no-restore`: + sucesso, 0 warnings, 0 errors. +- `dotnet test ./Dapper.FluentMap.sln --configuration Release --no-build`: + sucesso, 357 testes aprovados no total. +- `dotnet pack`: nao executado; este prompt alterou somente documentacao SDD e + nao mudou empacotamento ou codigo produtivo. + +## Interacao com Dapper TypeHandler + +Precedencia proposta para `QueryMapped*`: + +```text +property read converter + -> Dapper TypeHandler + -> FluentMap default conversion +``` + +Precedencia proposta para escrita futura: + +```text +property write converter + -> Dapper TypeHandler + -> Dapper/provider parameter default +``` + +APIs normais do Dapper continuam fora do controle property-scoped do FluentMap: + +```text +connection.Query() + -> Dapper type map para nomes/construtores + -> Dapper/provider conversion +``` + +## Arquivos importantes + +- `.sdd/etapa-10/01-conversion-landscape.md` +- `.sdd/etapa-10/02-property-conversion-spec.md` +- `.sdd/etapa-10/DECISIONS.md` +- `.sdd/etapa-10/STATUS.md` +- `src/Dapper.FluentMap/Materialization/NestedMaterializationPlan.cs` +- `src/Dapper.FluentMap/Materialization/MappedRowMaterializer.cs` +- `src/Dapper.FluentMap/Compatibility/DapperTypeHandlerAdapter.cs` +- `src/Dapper.FluentMap.Generators/MappingRegistrationGenerator.cs` +- `src/Dapper.FluentMap.Analyzers/FluentMapConfigurationAnalyzer.cs` +- `src/Dapper.FluentMap/Mapping/PropertyMap.cs` +- `src/Dapper.FluentMap/Mapping/PropertyPersistenceMetadata.cs` +- `src/Dapper.FluentMap/MappingRegistry.cs` +- `src/Dapper.FluentMap.Dommel/Resolvers/DommelPersistenceSqlBuilder.cs` +- `src/Dapper.FluentMap.Dommel/Resolvers/DommelPersistenceMetadata.cs` +- `src/Dapper.FluentMap.Dommel/Resolvers/DommelPropertyResolver.cs` +- `test/Dapper.FluentMap.Tests/ValueObjectMaterializationTests.cs` +- `test/Dapper.FluentMap.Tests/AdvancedQueryHardeningTests.cs` + +## Ultimo prompt executado + +Ultimo prompt executado: 10.1 From 24bc34f64d1c59930424de2f95c5196d78763e6e Mon Sep 17 00:00:00 2001 From: Rodrigo de Oliveira Date: Tue, 28 Jul 2026 16:55:19 -0300 Subject: [PATCH 23/49] feat(mapping): add property converter metadata --- .sdd/etapa-10/03-converter-contract-design.md | 253 ++++++++ .sdd/etapa-10/DECISIONS.md | 25 + .sdd/etapa-10/STATUS.md | 68 ++- README.md | 31 + .../Diagnostics/MemberMappingExplanation.cs | 9 +- .../Mapping/PropertyConversionMetadata.cs | 479 +++++++++++++++ src/Dapper.FluentMap/Mapping/PropertyMap.cs | 185 +++++- src/Dapper.FluentMap/MappingRegistry.cs | 11 +- .../PropertyConversionMetadataTests.cs | 577 ++++++++++++++++++ 9 files changed, 1620 insertions(+), 18 deletions(-) create mode 100644 .sdd/etapa-10/03-converter-contract-design.md create mode 100644 src/Dapper.FluentMap/Mapping/PropertyConversionMetadata.cs create mode 100644 test/Dapper.FluentMap.Tests/PropertyConversionMetadataTests.cs diff --git a/.sdd/etapa-10/03-converter-contract-design.md b/.sdd/etapa-10/03-converter-contract-design.md new file mode 100644 index 0000000..d67acf0 --- /dev/null +++ b/.sdd/etapa-10/03-converter-contract-design.md @@ -0,0 +1,253 @@ +# Etapa 10 - Converter Contract Design + +## Escopo do Prompt 10.2 + +Este incremento adiciona contratos, fluent API e metadata por propriedade para +conversores. Ele nao executa conversao em `QueryMapped*`, Dapper puro ou Dommel. +A decisao evita espalhar logica de execucao antes de haver equivalencia +runtime/generated e hook de escrita validado. + +## Contratos publicos + +Contratos adicionados em `Dapper.FluentMap.Mapping`: + +```csharp +public interface IReadPropertyConverter +{ + TProperty ConvertFromDatabase(TDatabase value); +} + +public interface IWritePropertyConverter +{ + TDatabase ConvertToDatabase(TProperty value); +} + +public interface IPropertyConverter : + IReadPropertyConverter, + IWritePropertyConverter +{ +} +``` + +Delegates direcionais tambem existem para configuracao leve: + +```csharp +public delegate TProperty ReadPropertyConverter( + TDatabase value); + +public delegate TDatabase WritePropertyConverter( + TProperty value); +``` + +`IPropertyConverter` e invariante porque os dois tipos +aparecem em posicoes de entrada e saida quando as duas direcoes sao combinadas. + +## Fluent API escolhida + +APIs por tipo de converter: + +```csharp +Map(x => x.Status) + .ConvertFromDatabaseUsing(); + +Map(x => x.Status) + .ConvertToDatabaseUsing(); + +Map(x => x.Status) + .ConvertUsing(); +``` + +APIs por instancia: + +```csharp +Map(x => x.Status) + .ConvertFromDatabaseUsing(new StatusReadConverter()); + +Map(x => x.Status) + .ConvertToDatabaseUsing(new StatusWriteConverter()); + +Map(x => x.Status) + .ConvertUsing(new StatusTextConverter()); +``` + +APIs por delegate: + +```csharp +Map(x => x.Status) + .ConvertFromDatabaseUsing(value => Status.Parse(value)) + .ConvertToDatabaseUsing(value => value.Code); +``` + +A API existente `Map(Expression>)` nao carrega +`TProperty`, portanto os overloads por tipo detectam incompatibilidade em +configuration time. Os overloads por instancia/delegate preservam maior +type-safety em build time quando o compilador infere os tipos do contrato. + +## Direcoes + +Read direction: + +```text +Database/provider CLR value -> Property CLR value +``` + +Write direction: + +```text +Property CLR value -> Database/provider CLR value +``` + +As direcoes sao independentes. Um property map pode configurar apenas leitura, +apenas escrita ou ambas. `ConvertUsing` exige que o converter implemente as duas +direcoes compativeis. + +## Null handling + +O contrato documentado para execucao futura e: + +- `null` e `DBNull.Value` nao serao enviados ao converter por default; +- nullable/reference recebem `null`; +- value type nao nullable recebe `default(T)`; +- `Nullable` e `T` sao aceitos como compativeis na configuracao. + +O Prompt 10.2 apenas guarda metadata e valida tipos; nao altera a execucao de +null em materializers. + +## Lifetime + +Estrategia implementada: + +- `Convert...Using()`: cria uma instancia por property + map no momento de construcao do map; +- overload por instancia: reutiliza a instancia fornecida pelo usuario; +- overload por delegate: reutiliza o delegate fornecido pelo usuario; +- sem DI, factory ou escopo por query nesta etapa. + +Conversores sao tratados como stateless/thread-safe por contrato. Se o usuario +fornecer uma instancia stateful, a thread safety e responsabilidade dele. + +## Metadata + +Metadata publica aditiva: + +- `PropertyConversionMetadata`; +- `PropertyConverterMetadata`; +- `PropertyConversionDirection`; +- `IPropertyMapWithConversionMetadata`. + +`PropertyConversionMetadata` responde: + +- `HasReadConverter`; +- `HasWriteConverter`; +- `ReadConverter`; +- `WriteConverter`. + +Cada `PropertyConverterMetadata` responde: + +- `Direction`; +- `ConverterType`; +- `DatabaseType`; +- `PropertyType`. + +A instancia real do converter fica armazenada internamente no descriptor. Isso +evita expor estado mutavel como contrato publico e preserva um ponto de +execucao futuro. + +`MemberMappingExplanation.Conversion` expoe um snapshot read-only da metadata +efetiva. O profile scope nao e duplicado dentro da property metadata; ele vem do +`MappingExplanation.ProfileType` e da origem do map efetivo. + +## Profile behavior + +Profiles continuam maps separados. Conversores configurados em um +`IProfileMap` valem somente para aquele profile. + +```text +QueryMapped() + usa metadata do profile registrado + +QueryMapped() + usa metadata do map default +``` + +Nao ha vazamento automatico de converter do map default para profile. + +## Inheritance + +`IncludeBase()` preserva conversion metadata dos property maps herdados. +Quando o map derivado declara explicitamente o mesmo member path, a precedencia +existente continua valendo: + +```text +derived explicit mapping + -> inherited base explicit mapping +``` + +Isso evita merge silencioso entre converters contraditorios. O property map +efetivo e unico. + +## Duplicate configuration + +Configuracoes duplicadas sao invalidas: + +- segundo read converter no mesmo property map: erro; +- segundo write converter no mesmo property map: erro; +- `ConvertUsing` falha se read ou write ja existir; +- profile duplicate continua sendo rejeitado por entity/profile key. + +## Precedence futura de execucao + +Sem alterar execucao neste prompt, a metadata foi desenhada para a seguinte +precedencia futura no caminho `QueryMapped*`: + +```text +property read converter + -> Dapper TypeHandler + -> FluentMap default conversion +``` + +Para escrita futura: + +```text +property write converter + -> Dapper TypeHandler + -> Dapper/provider parameter default +``` + +## Generated materializers + +O source generator ja deixa de emitir generated materializer quando encontra +metodo fluent nao suportado na chain. Como `Convert...` e novo, maps com +converter caem para runtime fallback no caminho gerado. + +O registry tambem rejeita descriptors generated manuais quando o effective +mapping para a coluna possui read converter. Isso evita escolher um materializer +que nao declarou nem aplicou a conversao de leitura. + +Write-only converters nao bloqueiam materializer de leitura. + +## Invalid configuration + +Erros cobertos em configuration time: + +- converter sem contrato direcional requerido; +- database/source type declarado incompatibil com o converter; +- property/destination type retornado incompatibil com a propriedade; +- read converter duplicado; +- write converter duplicado; +- profile collision; +- override derivado preservando precedencia explicita. + +As excecoes usam `FluentMapConfigurationException` para manter o padrao publico +existente do projeto. + +## Compatibilidade + +Sem converter configurado: + +- `IPropertyMap` permanece inalterada; +- maps existentes continuam compilando; +- `Query()`, `QueryMapped*`, generated materializers e Dommel nao mudam + comportamento de valor; +- diagnostics ganham metadata aditiva em `MemberMappingExplanation.Conversion`. + diff --git a/.sdd/etapa-10/DECISIONS.md b/.sdd/etapa-10/DECISIONS.md index 5a4907b..fe1d801 100644 --- a/.sdd/etapa-10/DECISIONS.md +++ b/.sdd/etapa-10/DECISIONS.md @@ -283,3 +283,28 @@ anotada ou causar fallback. Documentacao deve separar "supported at runtime" de "generated/AOT-friendly". Smoke AOT deve entrar somente quando houver implementacao generated. + +## ADR-11 - Prompt 10.2 converter metadata increment + +### Contexto + +O Prompt 10.2 pediu infraestrutura minima para representar conversoes por +propriedade sem espalhar execucao por toda a biblioteca. + +### Decisao + +Foram implementados contratos publicos tipados, overloads fluent por tipo, +instancia e delegate, metadata aditiva em `PropertyMap` e diagnostics via +`Explain`. A execucao de conversores em runtime materializer, generated +materializer e escrita Dommel ficou fora deste incremento. + +Generated materializers manuais nao sao selecionados quando o effective mapping +da coluna possui read converter, porque o descriptor atual nao declara nem +aplica conversao de leitura. Write-only converter nao bloqueia materializer de +leitura. + +### Consequencias + +O projeto passa a conseguir validar e inspecionar converters por propriedade, +profile e heranca, mantendo comportamento de valor inalterado ate a etapa que +implementar execucao. A API e aditiva e preserva `IPropertyMap`. diff --git a/.sdd/etapa-10/STATUS.md b/.sdd/etapa-10/STATUS.md index ab06992..910fc70 100644 --- a/.sdd/etapa-10/STATUS.md +++ b/.sdd/etapa-10/STATUS.md @@ -34,22 +34,40 @@ tipo e abrindo espaco para conversao por propriedade, map e profile. - Criado `.sdd/etapa-10/02-property-conversion-spec.md`. - Criado `.sdd/etapa-10/DECISIONS.md`. - Criado `.sdd/etapa-10/STATUS.md`. +- Criado `.sdd/etapa-10/03-converter-contract-design.md`. +- Implementados contratos publicos de conversao por propriedade: + `IReadPropertyConverter`, + `IWritePropertyConverter`, + `IPropertyConverter`, + `ReadPropertyConverter` e + `WritePropertyConverter`. +- Implementada fluent API em `PropertyMapBase`: + `ConvertFromDatabaseUsing`, `ConvertToDatabaseUsing` e `ConvertUsing`. +- Implementada metadata aditiva: + `PropertyConversionMetadata`, `PropertyConverterMetadata`, + `PropertyConversionDirection` e `IPropertyMapWithConversionMetadata`. +- `MemberMappingExplanation` passou a expor `Conversion`. +- Registry passa a rejeitar generated materializer manual quando o mapping + efetivo possui read converter, evitando execucao generated sem conversao. +- Adicionados testes de metadata/fluent API/read-only/write-only/bidirecional, + delegates, lifetime por property map, heranca, profile, invalid types, + duplicidade, nullability, profile collision e generated fallback defensivo. ## Em andamento -Nenhuma feature produtiva em andamento. Esta passada e SDD/arquitetura. +Execucao real de converters em runtime materializer, generated materializer e +write/Dommel permanece adiada para incrementos seguintes. ## Proximos passos -1. Implementar metadata/contracts aditivos de conversao sem alterar - comportamento. -2. Adicionar read conversion no runtime materializer com testes de regressao. -3. Evoluir generated read conversion ou fallback seguro quando houver converter. -4. Cobrir profile, inherited maps, nested leaves e Value Objects. -5. Investigar e implementar write conversion/Dommel somente apos definir hook +1. Adicionar read conversion no runtime materializer com testes de regressao. +2. Evoluir generated read conversion ou fallback seguro quando houver converter. +3. Cobrir nested leaves e Value Objects com execucao real de converter. +4. Investigar e implementar write conversion/Dommel somente apos definir hook de parametros por propriedade. -6. Evoluir diagnostics/analyzers. -7. Medir performance e documentar API publica. +5. Evoluir diagnostics/analyzers para reconhecer `Convert...`. +6. Medir performance e documentar API publica no README quando a execucao for + ativada. ## Decisoes relevantes @@ -67,10 +85,10 @@ Nenhuma feature produtiva em andamento. Esta passada e SDD/arquitetura. - Converters sao stateless/thread-safe por contrato e reutilizados. - AOT exige caminho por instancia/delegate ou referencia estatica gerada; nao deve depender de ativacao reflection-only. +- Prompt 10.2 decidiu implementar somente contracts/metadata/fluent API e + diagnostics, mantendo execucao de conversores para incremento posterior. -## APIs propostas - -APIs conceituais, ainda nao implementadas: +## APIs implementadas no Prompt 10.2 ```csharp Map(x => x.Status) @@ -90,7 +108,7 @@ Map(x => x.Status) .ConvertUsing(); ``` -Contratos conceituais: +Contratos implementados: ```csharp public interface IReadPropertyConverter @@ -122,6 +140,12 @@ public interface IPropertyConverter : introduzida cedo demais. - Converter por reflection precisa de anotacoes de trimming e estrategia AOT. - Caches atuais assumem configuracao efetivamente imutavel apos registro. +- Converter metadata ja existe, mas `QueryMapped*` ainda nao executa os + conversores. Isso e intencional no Prompt 10.2 para evitar divergencia + runtime/generated antes da proxima implementacao. +- `Convert...Using()` valida contrato por reflection de + interfaces em configuration time; overloads por instancia/delegate oferecem + caminho mais favoravel a AOT. ## Validacao do Prompt 10.1 @@ -133,6 +157,19 @@ public interface IPropertyConverter : - `dotnet pack`: nao executado; este prompt alterou somente documentacao SDD e nao mudou empacotamento ou codigo produtivo. +## Validacao do Prompt 10.2 + +- `dotnet test ./test/Dapper.FluentMap.Tests/Dapper.FluentMap.Tests.csproj --configuration Release --filter FullyQualifiedName~PropertyConversionMetadataTests`: + sucesso, 17 testes aprovados. +- `dotnet restore ./Dapper.FluentMap.sln`: sucesso. +- `dotnet build ./Dapper.FluentMap.sln --configuration Release --no-restore`: + sucesso, 0 warnings, 0 errors. +- `dotnet test ./Dapper.FluentMap.sln --configuration Release --no-build`: + sucesso, 374 testes aprovados no total. +- `dotnet pack ./src/Dapper.FluentMap/Dapper.FluentMap.csproj --configuration Release --no-build --output ./artifacts/packages`: + sucesso, pacote criado em `artifacts/packages/Dapper.FluentMap.2.0.0.nupkg`; + warning conhecido `NU5125` sobre `licenseUrl` depreciado. + ## Interacao com Dapper TypeHandler Precedencia proposta para `QueryMapped*`: @@ -163,6 +200,7 @@ connection.Query() - `.sdd/etapa-10/01-conversion-landscape.md` - `.sdd/etapa-10/02-property-conversion-spec.md` +- `.sdd/etapa-10/03-converter-contract-design.md` - `.sdd/etapa-10/DECISIONS.md` - `.sdd/etapa-10/STATUS.md` - `src/Dapper.FluentMap/Materialization/NestedMaterializationPlan.cs` @@ -171,6 +209,7 @@ connection.Query() - `src/Dapper.FluentMap.Generators/MappingRegistrationGenerator.cs` - `src/Dapper.FluentMap.Analyzers/FluentMapConfigurationAnalyzer.cs` - `src/Dapper.FluentMap/Mapping/PropertyMap.cs` +- `src/Dapper.FluentMap/Mapping/PropertyConversionMetadata.cs` - `src/Dapper.FluentMap/Mapping/PropertyPersistenceMetadata.cs` - `src/Dapper.FluentMap/MappingRegistry.cs` - `src/Dapper.FluentMap.Dommel/Resolvers/DommelPersistenceSqlBuilder.cs` @@ -178,7 +217,8 @@ connection.Query() - `src/Dapper.FluentMap.Dommel/Resolvers/DommelPropertyResolver.cs` - `test/Dapper.FluentMap.Tests/ValueObjectMaterializationTests.cs` - `test/Dapper.FluentMap.Tests/AdvancedQueryHardeningTests.cs` +- `test/Dapper.FluentMap.Tests/PropertyConversionMetadataTests.cs` ## Ultimo prompt executado -Ultimo prompt executado: 10.1 +Ultimo prompt executado: 10.2 diff --git a/README.md b/README.md index 0e8c085..0cd896d 100644 --- a/README.md +++ b/README.md @@ -159,6 +159,22 @@ Map(product => product.Total) Computed properties participate in reads and are excluded from generated `INSERT` and `UPDATE` metadata. +### Property Conversion Metadata + +Property converters can be attached to a mapping as metadata for future +read/write conversion paths: + +```csharp +Map(product => product.Status) + .ConvertFromDatabaseUsing() + .ConvertToDatabaseUsing(); +``` + +The current increment stores and validates converter metadata per property, +including profile and inherited mappings. It does not yet execute those +converters during Dapper queries, `QueryMapped*` materialization or Dommel +write operations. + Inherited explicit mappings can be included when the derived entity should reuse a base entity map: ```csharp @@ -754,6 +770,21 @@ Map(product => product.Total) Propriedades computed participam de leituras e são excluídas da metadata de `INSERT` e `UPDATE` gerados. +### Metadata de Conversao por Propriedade + +Conversores podem ser anexados a um mapping como metadata para caminhos futuros +de conversao de leitura/escrita: + +```csharp +Map(product => product.Status) + .ConvertFromDatabaseUsing() + .ConvertToDatabaseUsing(); +``` + +O incremento atual armazena e valida metadata de conversor por propriedade, +incluindo profiles e mappings herdados. Ele ainda nao executa esses conversores +em consultas Dapper, materializacao `QueryMapped*` ou escritas Dommel. + Mapeamentos explícitos herdados podem ser incluídos quando a entidade derivada deve reutilizar um map da entidade base: ```csharp diff --git a/src/Dapper.FluentMap/Diagnostics/MemberMappingExplanation.cs b/src/Dapper.FluentMap/Diagnostics/MemberMappingExplanation.cs index 4482c96..cde7e20 100644 --- a/src/Dapper.FluentMap/Diagnostics/MemberMappingExplanation.cs +++ b/src/Dapper.FluentMap/Diagnostics/MemberMappingExplanation.cs @@ -23,7 +23,8 @@ internal MemberMappingExplanation( Type conventionType, IEnumerable constructorParameters, MappingMaterialization materialization, - PropertyPersistenceMetadata persistence) + PropertyPersistenceMetadata persistence, + PropertyConversionMetadata conversion) { if (string.IsNullOrEmpty(memberPath)) { @@ -47,6 +48,7 @@ internal MemberMappingExplanation( (constructorParameters ?? Enumerable.Empty()).ToList()); Materialization = materialization; Persistence = persistence ?? PropertyPersistenceMetadata.Default; + Conversion = conversion ?? PropertyConversionMetadata.Default; } /// @@ -103,5 +105,10 @@ internal MemberMappingExplanation( /// Gets the persistence metadata associated with this member mapping. /// public PropertyPersistenceMetadata Persistence { get; } + + /// + /// Gets the conversion metadata associated with this member mapping. + /// + public PropertyConversionMetadata Conversion { get; } } } diff --git a/src/Dapper.FluentMap/Mapping/PropertyConversionMetadata.cs b/src/Dapper.FluentMap/Mapping/PropertyConversionMetadata.cs new file mode 100644 index 0000000..dc97f2a --- /dev/null +++ b/src/Dapper.FluentMap/Mapping/PropertyConversionMetadata.cs @@ -0,0 +1,479 @@ +using System; +using System.Linq; +using System.Reflection; + +namespace Dapper.FluentMap.Mapping +{ + /// + /// Converts database/provider values to property values for one mapped property. + /// + /// The CLR type produced by the database provider. + /// The mapped property CLR type. + public interface IReadPropertyConverter + { + /// + /// Converts a non-null database/provider value to a property value. + /// + /// The database/provider value. + /// The converted property value. + TProperty ConvertFromDatabase(TDatabase value); + } + + /// + /// Converts property values to database/provider values for one mapped property. + /// + /// The mapped property CLR type. + /// The CLR type sent to the database provider. + public interface IWritePropertyConverter + { + /// + /// Converts a non-null property value to a database/provider value. + /// + /// The property value. + /// The converted database/provider value. + TDatabase ConvertToDatabase(TProperty value); + } + + /// + /// Converts values in both read and write directions for one mapped property. + /// + /// The database/provider CLR type. + /// The mapped property CLR type. + public interface IPropertyConverter : + IReadPropertyConverter, + IWritePropertyConverter + { + } + + /// + /// Converts database/provider values to property values using a delegate. + /// + /// The CLR type produced by the database provider. + /// The mapped property CLR type. + /// The database/provider value. + /// The converted property value. + public delegate TProperty ReadPropertyConverter(TDatabase value); + + /// + /// Converts property values to database/provider values using a delegate. + /// + /// The mapped property CLR type. + /// The CLR type sent to the database provider. + /// The property value. + /// The converted database/provider value. + public delegate TDatabase WritePropertyConverter(TProperty value); + + /// + /// Identifies the direction of a property converter descriptor. + /// + public enum PropertyConversionDirection + { + /// + /// Database/provider value to property value. + /// + Read, + + /// + /// Property value to database/provider value. + /// + Write + } + + /// + /// Describes a configured converter for one property and one conversion direction. + /// + public sealed class PropertyConverterMetadata + { + internal PropertyConverterMetadata( + PropertyConversionDirection direction, + Type converterType, + Type databaseType, + Type propertyType, + object converter) + { + Direction = direction; + ConverterType = converterType ?? throw new ArgumentNullException(nameof(converterType)); + DatabaseType = databaseType ?? throw new ArgumentNullException(nameof(databaseType)); + PropertyType = propertyType ?? throw new ArgumentNullException(nameof(propertyType)); + Converter = converter ?? throw new ArgumentNullException(nameof(converter)); + } + + /// + /// Gets the conversion direction represented by this descriptor. + /// + public PropertyConversionDirection Direction { get; } + + /// + /// Gets the configured converter type. + /// + public Type ConverterType { get; } + + /// + /// Gets the declared database/provider CLR type. + /// + public Type DatabaseType { get; } + + /// + /// Gets the declared property CLR type used by the converter. + /// + public Type PropertyType { get; } + + internal object Converter { get; } + } + + /// + /// Describes property converter metadata configured for a mapped property. + /// + public sealed class PropertyConversionMetadata + { + /// + /// Gets the default conversion metadata for a property without configured converters. + /// + public static readonly PropertyConversionMetadata Default = + new PropertyConversionMetadata(readConverter: null, writeConverter: null); + + private PropertyConversionMetadata( + PropertyConverterMetadata readConverter, + PropertyConverterMetadata writeConverter) + { + ReadConverter = readConverter; + WriteConverter = writeConverter; + } + + /// + /// Gets a value indicating whether a read converter is configured. + /// + public bool HasReadConverter => ReadConverter != null; + + /// + /// Gets a value indicating whether a write converter is configured. + /// + public bool HasWriteConverter => WriteConverter != null; + + /// + /// Gets the read converter descriptor, or when no read converter is configured. + /// + public PropertyConverterMetadata ReadConverter { get; } + + /// + /// Gets the write converter descriptor, or when no write converter is configured. + /// + public PropertyConverterMetadata WriteConverter { get; } + + internal PropertyConversionMetadata WithReadConverter(PropertyConverterMetadata converter) + { + if (converter == null) + { + throw new ArgumentNullException(nameof(converter)); + } + + if (HasReadConverter) + { + throw new FluentMapConfigurationException( + $"A read converter is already configured for property type '{ReadConverter.PropertyType.FullName}'."); + } + + return new PropertyConversionMetadata(converter, WriteConverter); + } + + internal PropertyConversionMetadata WithWriteConverter(PropertyConverterMetadata converter) + { + if (converter == null) + { + throw new ArgumentNullException(nameof(converter)); + } + + if (HasWriteConverter) + { + throw new FluentMapConfigurationException( + $"A write converter is already configured for property type '{WriteConverter.PropertyType.FullName}'."); + } + + return new PropertyConversionMetadata(ReadConverter, converter); + } + } + + /// + /// Exposes property conversion metadata without changing the original contract. + /// + public interface IPropertyMapWithConversionMetadata + { + /// + /// Gets the configured conversion metadata for the property. + /// + PropertyConversionMetadata Conversion { get; } + } + + internal static class PropertyMapConversion + { + internal static PropertyConversionMetadata GetConversion(IPropertyMap propertyMap) + { + if (propertyMap == null) + { + throw new ArgumentNullException(nameof(propertyMap)); + } + + var mapWithConversion = propertyMap as IPropertyMapWithConversionMetadata; + return mapWithConversion == null + ? PropertyConversionMetadata.Default + : mapWithConversion.Conversion; + } + + internal static PropertyConverterMetadata CreateReadConverter( + ReadPropertyConverter converter, + Type mappedPropertyType) + { + if (converter == null) + { + throw new ArgumentNullException(nameof(converter)); + } + + EnsureReadPropertyType(mappedPropertyType, typeof(TProperty), converter.GetType()); + return new PropertyConverterMetadata( + PropertyConversionDirection.Read, + typeof(ReadPropertyConverter), + typeof(TDatabase), + typeof(TProperty), + converter); + } + + internal static PropertyConverterMetadata CreateReadConverter( + IReadPropertyConverter converter, + Type mappedPropertyType) + { + if (converter == null) + { + throw new ArgumentNullException(nameof(converter)); + } + + EnsureReadPropertyType(mappedPropertyType, typeof(TProperty), converter.GetType()); + return new PropertyConverterMetadata( + PropertyConversionDirection.Read, + converter.GetType(), + typeof(TDatabase), + typeof(TProperty), + converter); + } + + internal static PropertyConverterMetadata CreateReadConverter( + Type converterType, + Type databaseType, + Type mappedPropertyType, + object converter) + { + if (converterType == null) + { + throw new ArgumentNullException(nameof(converterType)); + } + + if (databaseType == null) + { + throw new ArgumentNullException(nameof(databaseType)); + } + + if (converter == null) + { + throw new ArgumentNullException(nameof(converter)); + } + + var converterInterface = FindConverterInterface( + converterType, + typeof(IReadPropertyConverter<,>), + databaseType, + mappedPropertyType, + readDirection: true); + + if (converterInterface == null) + { + throw new FluentMapConfigurationException( + $"Converter type '{converterType.FullName}' is not compatible with read conversion from database type '{databaseType.FullName}' to mapped property type '{mappedPropertyType.FullName}'."); + } + + return new PropertyConverterMetadata( + PropertyConversionDirection.Read, + converterType, + converterInterface.GetGenericArguments()[0], + converterInterface.GetGenericArguments()[1], + converter); + } + + internal static PropertyConverterMetadata CreateWriteConverter( + WritePropertyConverter converter, + Type mappedPropertyType) + { + if (converter == null) + { + throw new ArgumentNullException(nameof(converter)); + } + + EnsureWritePropertyType(mappedPropertyType, typeof(TProperty), converter.GetType()); + return new PropertyConverterMetadata( + PropertyConversionDirection.Write, + typeof(WritePropertyConverter), + typeof(TDatabase), + typeof(TProperty), + converter); + } + + internal static PropertyConverterMetadata CreateWriteConverter( + IWritePropertyConverter converter, + Type mappedPropertyType) + { + if (converter == null) + { + throw new ArgumentNullException(nameof(converter)); + } + + EnsureWritePropertyType(mappedPropertyType, typeof(TProperty), converter.GetType()); + return new PropertyConverterMetadata( + PropertyConversionDirection.Write, + converter.GetType(), + typeof(TDatabase), + typeof(TProperty), + converter); + } + + internal static PropertyConverterMetadata CreateWriteConverter( + Type converterType, + Type databaseType, + Type mappedPropertyType, + object converter) + { + if (converterType == null) + { + throw new ArgumentNullException(nameof(converterType)); + } + + if (databaseType == null) + { + throw new ArgumentNullException(nameof(databaseType)); + } + + if (converter == null) + { + throw new ArgumentNullException(nameof(converter)); + } + + var converterInterface = FindConverterInterface( + converterType, + typeof(IWritePropertyConverter<,>), + databaseType, + mappedPropertyType, + readDirection: false); + + if (converterInterface == null) + { + throw new FluentMapConfigurationException( + $"Converter type '{converterType.FullName}' is not compatible with write conversion from mapped property type '{mappedPropertyType.FullName}' to database type '{databaseType.FullName}'."); + } + + return new PropertyConverterMetadata( + PropertyConversionDirection.Write, + converterType, + converterInterface.GetGenericArguments()[1], + converterInterface.GetGenericArguments()[0], + converter); + } + + private static Type FindConverterInterface( + Type converterType, + Type interfaceDefinition, + Type databaseType, + Type mappedPropertyType, + bool readDirection) + { + var databaseMatches = converterType + .GetTypeInfo() + .ImplementedInterfaces + .Where(type => type.GetTypeInfo().IsGenericType && + type.GetGenericTypeDefinition() == interfaceDefinition) + .Where(type => + { + var arguments = type.GetGenericArguments(); + var converterDatabaseType = readDirection ? arguments[0] : arguments[1]; + + return IsSameOrNullableEquivalent(converterDatabaseType, databaseType); + }) + .ToList(); + + if (databaseMatches.Count == 0) + { + return null; + } + + var matches = databaseMatches + .Where(type => + { + var arguments = type.GetGenericArguments(); + var converterPropertyType = readDirection ? arguments[1] : arguments[0]; + + return readDirection + ? CanAssignValue(mappedPropertyType, converterPropertyType) + : CanAssignValue(converterPropertyType, mappedPropertyType); + }) + .ToList(); + + if (matches.Count == 0) + { + var converterPropertyType = databaseMatches[0].GetGenericArguments()[readDirection ? 1 : 0]; + var reason = readDirection + ? $"returns '{converterPropertyType.FullName}', which cannot be assigned to mapped property type '{mappedPropertyType.FullName}'" + : $"accepts '{converterPropertyType.FullName}', which is not compatible with mapped property type '{mappedPropertyType.FullName}'"; + + throw new FluentMapConfigurationException( + $"Converter type '{converterType.FullName}' {reason}."); + } + + if (matches.Count > 1) + { + throw new FluentMapConfigurationException( + $"Converter type '{converterType.FullName}' matches more than one compatible '{interfaceDefinition.Name}' contract for property type '{mappedPropertyType.FullName}'."); + } + + return matches[0]; + } + + private static void EnsureReadPropertyType(Type mappedPropertyType, Type converterPropertyType, Type converterType) + { + if (!CanAssignValue(mappedPropertyType, converterPropertyType)) + { + throw new FluentMapConfigurationException( + $"Read converter type '{converterType.FullName}' returns '{converterPropertyType.FullName}', which cannot be assigned to mapped property type '{mappedPropertyType.FullName}'."); + } + } + + private static void EnsureWritePropertyType(Type mappedPropertyType, Type converterPropertyType, Type converterType) + { + if (!CanAssignValue(converterPropertyType, mappedPropertyType)) + { + throw new FluentMapConfigurationException( + $"Write converter type '{converterType.FullName}' accepts '{converterPropertyType.FullName}', which is not compatible with mapped property type '{mappedPropertyType.FullName}'."); + } + } + + private static bool CanAssignValue(Type targetType, Type valueType) + { + if (targetType == null) + { + throw new ArgumentNullException(nameof(targetType)); + } + + if (valueType == null) + { + throw new ArgumentNullException(nameof(valueType)); + } + + if (IsSameOrNullableEquivalent(targetType, valueType)) + { + return true; + } + + return targetType.GetTypeInfo().IsAssignableFrom(valueType.GetTypeInfo()); + } + + private static bool IsSameOrNullableEquivalent(Type left, Type right) + { + return left == right || Nullable.GetUnderlyingType(left) == right || Nullable.GetUnderlyingType(right) == left; + } + } +} diff --git a/src/Dapper.FluentMap/Mapping/PropertyMap.cs b/src/Dapper.FluentMap/Mapping/PropertyMap.cs index 56b811e..2007467 100644 --- a/src/Dapper.FluentMap/Mapping/PropertyMap.cs +++ b/src/Dapper.FluentMap/Mapping/PropertyMap.cs @@ -1,5 +1,6 @@ using System; using System.ComponentModel; +using System.Diagnostics.CodeAnalysis; using System.Reflection; namespace Dapper.FluentMap.Mapping @@ -34,7 +35,10 @@ public interface IPropertyMap /// Serves as the base class for all property mapping implementations. /// /// The type of the property mapping. - public abstract class PropertyMapBase : IPropertyMapWithMemberPath, IPropertyMapWithPersistenceMetadata + public abstract class PropertyMapBase : + IPropertyMapWithMemberPath, + IPropertyMapWithPersistenceMetadata, + IPropertyMapWithConversionMetadata where TPropertyMap : class, IPropertyMap { /// @@ -53,6 +57,7 @@ protected PropertyMapBase(PropertyInfo info) MemberPath = Dapper.FluentMap.Mapping.MemberPath.ForProperty(info); ColumnName = info.Name; Persistence = PropertyPersistenceMetadata.Default; + Conversion = PropertyConversionMetadata.Default; } /// @@ -73,6 +78,7 @@ internal PropertyMapBase(PropertyInfo info, string columnName) MemberPath = Dapper.FluentMap.Mapping.MemberPath.ForProperty(info); ColumnName = columnName; Persistence = PropertyPersistenceMetadata.Default; + Conversion = PropertyConversionMetadata.Default; } /// @@ -95,6 +101,7 @@ internal PropertyMapBase(PropertyInfo info, string columnName, bool caseSensitiv ColumnName = columnName; CaseSensitive = caseSensitive; Persistence = PropertyPersistenceMetadata.Default; + Conversion = PropertyConversionMetadata.Default; } /// @@ -122,6 +129,11 @@ internal PropertyMapBase(PropertyInfo info, string columnName, bool caseSensitiv /// public PropertyPersistenceMetadata Persistence { get; private set; } + /// + /// Gets the conversion metadata configured for this property. + /// + public PropertyConversionMetadata Conversion { get; private set; } + internal MemberPath MemberPath { get; private set; } MemberPath IPropertyMapWithMemberPath.MemberPath => MemberPath; @@ -210,6 +222,177 @@ public TPropertyMap DatabaseDefaultOnInsert() return this as TPropertyMap; } + /// + /// Configures a read converter type for the current property. + /// + /// The converter type to instantiate for this property map. + /// The database/provider CLR type accepted by the converter. + /// The current instance of . + public TPropertyMap ConvertFromDatabaseUsing< + [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.Interfaces | DynamicallyAccessedMemberTypes.PublicParameterlessConstructor)] + TConverter, + TDatabase>() + where TConverter : new() + { + var converter = new TConverter(); + var readConverter = PropertyMapConversion.CreateReadConverter( + typeof(TConverter), + typeof(TDatabase), + PropertyInfo.PropertyType, + converter); + + Conversion = Conversion.WithReadConverter(readConverter); + return this as TPropertyMap; + } + + /// + /// Configures a read converter instance for the current property. + /// + /// The database/provider CLR type accepted by the converter. + /// The property CLR type returned by the converter. + /// The converter instance to reuse for this property map. + /// The current instance of . + public TPropertyMap ConvertFromDatabaseUsing( + IReadPropertyConverter converter) + { + var readConverter = PropertyMapConversion.CreateReadConverter( + converter, + PropertyInfo.PropertyType); + + Conversion = Conversion.WithReadConverter(readConverter); + return this as TPropertyMap; + } + + /// + /// Configures a read converter delegate for the current property. + /// + /// The database/provider CLR type accepted by the converter. + /// The property CLR type returned by the converter. + /// The converter delegate to reuse for this property map. + /// The current instance of . + public TPropertyMap ConvertFromDatabaseUsing( + ReadPropertyConverter converter) + { + var readConverter = PropertyMapConversion.CreateReadConverter( + converter, + PropertyInfo.PropertyType); + + Conversion = Conversion.WithReadConverter(readConverter); + return this as TPropertyMap; + } + + /// + /// Configures a write converter type for the current property. + /// + /// The converter type to instantiate for this property map. + /// The database/provider CLR type returned by the converter. + /// The current instance of . + public TPropertyMap ConvertToDatabaseUsing< + [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.Interfaces | DynamicallyAccessedMemberTypes.PublicParameterlessConstructor)] + TConverter, + TDatabase>() + where TConverter : new() + { + var converter = new TConverter(); + var writeConverter = PropertyMapConversion.CreateWriteConverter( + typeof(TConverter), + typeof(TDatabase), + PropertyInfo.PropertyType, + converter); + + Conversion = Conversion.WithWriteConverter(writeConverter); + return this as TPropertyMap; + } + + /// + /// Configures a write converter instance for the current property. + /// + /// The property CLR type accepted by the converter. + /// The database/provider CLR type returned by the converter. + /// The converter instance to reuse for this property map. + /// The current instance of . + public TPropertyMap ConvertToDatabaseUsing( + IWritePropertyConverter converter) + { + var writeConverter = PropertyMapConversion.CreateWriteConverter( + converter, + PropertyInfo.PropertyType); + + Conversion = Conversion.WithWriteConverter(writeConverter); + return this as TPropertyMap; + } + + /// + /// Configures a write converter delegate for the current property. + /// + /// The property CLR type accepted by the converter. + /// The database/provider CLR type returned by the converter. + /// The converter delegate to reuse for this property map. + /// The current instance of . + public TPropertyMap ConvertToDatabaseUsing( + WritePropertyConverter converter) + { + var writeConverter = PropertyMapConversion.CreateWriteConverter( + converter, + PropertyInfo.PropertyType); + + Conversion = Conversion.WithWriteConverter(writeConverter); + return this as TPropertyMap; + } + + /// + /// Configures one bidirectional converter type for the current property. + /// + /// The converter type to instantiate for this property map. + /// The database/provider CLR type used by the converter. + /// The current instance of . + public TPropertyMap ConvertUsing< + [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.Interfaces | DynamicallyAccessedMemberTypes.PublicParameterlessConstructor)] + TConverter, + TDatabase>() + where TConverter : new() + { + var converter = new TConverter(); + var readConverter = PropertyMapConversion.CreateReadConverter( + typeof(TConverter), + typeof(TDatabase), + PropertyInfo.PropertyType, + converter); + var writeConverter = PropertyMapConversion.CreateWriteConverter( + typeof(TConverter), + typeof(TDatabase), + PropertyInfo.PropertyType, + converter); + + Conversion = Conversion + .WithReadConverter(readConverter) + .WithWriteConverter(writeConverter); + return this as TPropertyMap; + } + + /// + /// Configures one bidirectional converter instance for the current property. + /// + /// The database/provider CLR type used by the converter. + /// The property CLR type used by the converter. + /// The converter instance to reuse for this property map. + /// The current instance of . + public TPropertyMap ConvertUsing( + IPropertyConverter converter) + { + var readConverter = PropertyMapConversion.CreateReadConverter( + converter, + PropertyInfo.PropertyType); + var writeConverter = PropertyMapConversion.CreateWriteConverter( + converter, + PropertyInfo.PropertyType); + + Conversion = Conversion + .WithReadConverter(readConverter) + .WithWriteConverter(writeConverter); + return this as TPropertyMap; + } + /// /// Applies persistence metadata configured by derived mapping types. /// diff --git a/src/Dapper.FluentMap/MappingRegistry.cs b/src/Dapper.FluentMap/MappingRegistry.cs index 63634f0..a117780 100644 --- a/src/Dapper.FluentMap/MappingRegistry.cs +++ b/src/Dapper.FluentMap/MappingRegistry.cs @@ -511,6 +511,11 @@ private bool GeneratedMaterializerMatchesEffectiveMapping( return false; } + if (PropertyMapConversion.GetConversion(fluentMap).HasReadConverter) + { + return false; + } + var memberPath = PropertyMapIdentity.GetMemberPath(fluentMap).ToString(); if (!string.Equals(memberPath, column.MemberPath, StringComparison.Ordinal)) { @@ -757,7 +762,8 @@ private void AddDapperDefaultExplanations( conventionType: null, constructorParameters: constructorParameters, materialization: MappingMaterialization.Dapper, - persistence: PropertyPersistenceMetadata.Default)); + persistence: PropertyPersistenceMetadata.Default, + conversion: PropertyConversionMetadata.Default)); configuredPaths.Add(memberPath); } } @@ -786,7 +792,8 @@ private void AddMemberExplanation( descriptor.ConventionType, constructorParameters, materialization, - PropertyMapPersistence.GetPersistence(descriptor.Map))); + PropertyMapPersistence.GetPersistence(descriptor.Map), + PropertyMapConversion.GetConversion(descriptor.Map))); configuredPaths.Add(memberPath); } diff --git a/test/Dapper.FluentMap.Tests/PropertyConversionMetadataTests.cs b/test/Dapper.FluentMap.Tests/PropertyConversionMetadataTests.cs new file mode 100644 index 0000000..b97415e --- /dev/null +++ b/test/Dapper.FluentMap.Tests/PropertyConversionMetadataTests.cs @@ -0,0 +1,577 @@ +using System; +using System.Data; +using System.Linq; +using Dapper.FluentMap.Mapping; +using Dapper.FluentMap.Materialization; +using Xunit; + +namespace Dapper.FluentMap.Tests +{ + public class PropertyConversionMetadataTests + { + [Fact] + public void DefaultPropertyMapShouldNotHaveConverters() + { + var map = new DefaultConversionMap(); + + var conversion = ConversionOf(map); + + Assert.False(conversion.HasReadConverter); + Assert.False(conversion.HasWriteConverter); + Assert.Null(conversion.ReadConverter); + Assert.Null(conversion.WriteConverter); + } + + [Fact] + public void ReadOnlyConverterShouldExposeReadMetadata() + { + var map = new ReadOnlyConversionMap(); + + var conversion = ConversionOf(map); + + Assert.True(conversion.HasReadConverter); + Assert.False(conversion.HasWriteConverter); + Assert.Equal(typeof(StatusReadConverter), conversion.ReadConverter.ConverterType); + Assert.Equal(typeof(string), conversion.ReadConverter.DatabaseType); + Assert.Equal(typeof(AccountStatus), conversion.ReadConverter.PropertyType); + Assert.Equal(PropertyConversionDirection.Read, conversion.ReadConverter.Direction); + } + + [Fact] + public void WriteOnlyConverterShouldExposeWriteMetadata() + { + var map = new WriteOnlyConversionMap(); + + var conversion = ConversionOf(map); + + Assert.False(conversion.HasReadConverter); + Assert.True(conversion.HasWriteConverter); + Assert.Equal(typeof(StatusWriteConverter), conversion.WriteConverter.ConverterType); + Assert.Equal(typeof(string), conversion.WriteConverter.DatabaseType); + Assert.Equal(typeof(AccountStatus), conversion.WriteConverter.PropertyType); + Assert.Equal(PropertyConversionDirection.Write, conversion.WriteConverter.Direction); + } + + [Fact] + public void BidirectionalConverterShouldExposeBothDirections() + { + var map = new BidirectionalConversionMap(); + + var conversion = ConversionOf(map); + + Assert.True(conversion.HasReadConverter); + Assert.True(conversion.HasWriteConverter); + Assert.Equal(typeof(StatusTextConverter), conversion.ReadConverter.ConverterType); + Assert.Equal(typeof(StatusTextConverter), conversion.WriteConverter.ConverterType); + Assert.Equal(typeof(string), conversion.ReadConverter.DatabaseType); + Assert.Equal(typeof(string), conversion.WriteConverter.DatabaseType); + } + + [Fact] + public void DelegateConvertersShouldExposeDirectionalMetadata() + { + var map = new DelegateConversionMap(); + + var conversion = ConversionOf(map); + + Assert.True(conversion.HasReadConverter); + Assert.True(conversion.HasWriteConverter); + Assert.Equal(typeof(ReadPropertyConverter), conversion.ReadConverter.ConverterType); + Assert.Equal(typeof(WritePropertyConverter), conversion.WriteConverter.ConverterType); + } + + [Fact] + public void ConverterTypeShouldBeInstantiatedOncePerPropertyMap() + { + CountingReadConverter.Created = 0; + + var map = new CountingConversionMap(); + + Assert.True(ConversionOf(map).HasReadConverter); + Assert.Equal(1, CountingReadConverter.Created); + } + + [Fact] + public void NullablePropertyShouldAcceptNonNullableConverterResult() + { + var map = new NullableConversionMap(); + + var conversion = ConversionOf(map); + + Assert.True(conversion.HasReadConverter); + Assert.True(conversion.HasWriteConverter); + Assert.Equal(typeof(AccountStatus), conversion.ReadConverter.PropertyType); + Assert.Equal(typeof(AccountStatus), conversion.WriteConverter.PropertyType); + } + + [Fact] + public void InheritedMappingsShouldPreserveConversionMetadata() + { + PreTest(typeof(ConversionBaseEntity), typeof(ConversionDerivedEntity)); + + try + { + FluentMapper.Initialize(c => + { + c.AddMap(new ConversionBaseMap()); + c.AddMap(new ConversionDerivedMap()); + }); + + var explanation = FluentMapper.Explain(); + var status = explanation.Members.Single(m => m.MemberPath == nameof(ConversionBaseEntity.Status)); + + Assert.Equal(typeof(ConversionBaseEntity), status.InheritedFrom); + Assert.True(status.Conversion.HasReadConverter); + Assert.Equal(typeof(StatusReadConverter), status.Conversion.ReadConverter.ConverterType); + } + finally + { + PreTest(typeof(ConversionBaseEntity), typeof(ConversionDerivedEntity)); + } + } + + [Fact] + public void DerivedExplicitConverterShouldOverrideInheritedConverter() + { + PreTest(typeof(ConversionBaseEntity), typeof(ConversionDerivedEntity)); + + try + { + FluentMapper.Initialize(c => + { + c.AddMap(new ConversionBaseMap()); + c.AddMap(new ConversionDerivedOverrideMap()); + }); + + var explanation = FluentMapper.Explain(); + var status = explanation.Members.Single(m => m.MemberPath == nameof(ConversionBaseEntity.Status)); + + Assert.Null(status.InheritedFrom); + Assert.Equal(typeof(AlternateStatusReadConverter), status.Conversion.ReadConverter.ConverterType); + } + finally + { + PreTest(typeof(ConversionBaseEntity), typeof(ConversionDerivedEntity)); + } + } + + [Fact] + public void ProfileConverterShouldBeScopedToProfileMapping() + { + PreTest(typeof(ProfileConversionEntity)); + + try + { + FluentMapper.Initialize(c => + { + c.AddMap(new DefaultProfileConversionMap()); + c.AddProfile(); + }); + + var defaultStatus = FluentMapper.Explain() + .Members.Single(m => m.MemberPath == nameof(ProfileConversionEntity.Status)); + var profileStatus = FluentMapper.Explain() + .Members.Single(m => m.MemberPath == nameof(ProfileConversionEntity.Status)); + + Assert.False(defaultStatus.Conversion.HasReadConverter); + Assert.True(profileStatus.Conversion.HasReadConverter); + Assert.Equal(typeof(StatusReadConverter), profileStatus.Conversion.ReadConverter.ConverterType); + } + finally + { + PreTest(typeof(ProfileConversionEntity)); + } + } + + [Fact] + public void IncompatibleConverterShouldThrow() + { + var exception = Assert.Throws( + () => new IncompatibleConverterMap()); + + Assert.Contains("not compatible with read conversion", exception.Message); + } + + [Fact] + public void SourceTypeMismatchShouldThrow() + { + var exception = Assert.Throws( + () => new SourceMismatchConversionMap()); + + Assert.Contains(typeof(int).FullName, exception.Message); + Assert.Contains("read conversion", exception.Message); + } + + [Fact] + public void DestinationTypeMismatchShouldThrow() + { + var exception = Assert.Throws( + () => new DestinationMismatchConversionMap()); + + Assert.Contains("cannot be assigned", exception.Message); + Assert.Contains(typeof(int).FullName, exception.Message); + } + + [Fact] + public void DuplicateReadConverterShouldThrow() + { + var exception = Assert.Throws( + () => new DuplicateReadConversionMap()); + + Assert.Contains("read converter is already configured", exception.Message); + } + + [Fact] + public void DuplicateWriteConverterShouldThrow() + { + var exception = Assert.Throws( + () => new DuplicateWriteConversionMap()); + + Assert.Contains("write converter is already configured", exception.Message); + } + + [Fact] + public void DuplicateProfileRegistrationWithConvertersShouldThrow() + { + PreTest(typeof(ProfileConversionEntity)); + + 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(LegacyConversionProfile).FullName, exception.Message); + } + finally + { + PreTest(typeof(ProfileConversionEntity)); + } + } + + [Fact] + public void GeneratedMaterializerShouldNotMatchReadConverterMetadata() + { + PreTest(typeof(ConversionEntity)); + + try + { + FluentMapper.Initialize(c => + { + c.AddMap(new ReadOnlyConversionMap()); + c.AddGeneratedMaterializer( + new[] { GeneratedMaterializerColumn.Map("status", nameof(ConversionEntity.Status)) }, + ReadGeneratedConversionEntity); + }); + + var found = FluentMapper.Registry.TryGetGeneratedMaterializer( + typeof(ConversionEntity), + profileType: null, + columnNames: new[] { "status" }, + out var materializer); + + Assert.False(found); + Assert.Null(materializer); + } + finally + { + PreTest(typeof(ConversionEntity)); + } + } + + private static PropertyConversionMetadata ConversionOf(IEntityMap map) + { + return ((IPropertyMapWithConversionMetadata)map.PropertyMaps.Single()).Conversion; + } + + private static ConversionEntity ReadGeneratedConversionEntity(IDataRecord record) + { + return new ConversionEntity + { + Status = AccountStatus.Active + }; + } + + private static void PreTest(params Type[] types) + { + FluentMapper.Reset(types); + } + + private enum AccountStatus + { + Unknown, + Active, + Inactive + } + + private sealed class ConversionEntity + { + public AccountStatus Status { get; set; } + + public AccountStatus? OptionalStatus { get; set; } + } + + private sealed class DefaultConversionMap : EntityMap + { + public DefaultConversionMap() + { + Map(e => e.Status).ToColumn("status"); + } + } + + private sealed class ReadOnlyConversionMap : EntityMap + { + public ReadOnlyConversionMap() + { + Map(e => e.Status) + .ToColumn("status") + .ConvertFromDatabaseUsing(); + } + } + + private sealed class WriteOnlyConversionMap : EntityMap + { + public WriteOnlyConversionMap() + { + Map(e => e.Status) + .ToColumn("status") + .ConvertToDatabaseUsing(); + } + } + + private sealed class BidirectionalConversionMap : EntityMap + { + public BidirectionalConversionMap() + { + Map(e => e.Status) + .ToColumn("status") + .ConvertUsing(); + } + } + + private sealed class DelegateConversionMap : EntityMap + { + public DelegateConversionMap() + { + Map(e => e.Status) + .ConvertFromDatabaseUsing(value => AccountStatus.Active) + .ConvertToDatabaseUsing(value => value.ToString()); + } + } + + private sealed class CountingConversionMap : EntityMap + { + public CountingConversionMap() + { + Map(e => e.Status).ConvertFromDatabaseUsing(); + } + } + + private sealed class NullableConversionMap : EntityMap + { + public NullableConversionMap() + { + Map(e => e.OptionalStatus) + .ConvertFromDatabaseUsing() + .ConvertToDatabaseUsing(); + } + } + + private sealed class IncompatibleConverterMap : EntityMap + { + public IncompatibleConverterMap() + { + Map(e => e.Status).ConvertFromDatabaseUsing(); + } + } + + private sealed class SourceMismatchConversionMap : EntityMap + { + public SourceMismatchConversionMap() + { + Map(e => e.Status).ConvertFromDatabaseUsing(); + } + } + + private sealed class DestinationMismatchConversionMap : EntityMap + { + public DestinationMismatchConversionMap() + { + Map(e => e.Status).ConvertFromDatabaseUsing(); + } + } + + private sealed class DuplicateReadConversionMap : EntityMap + { + public DuplicateReadConversionMap() + { + Map(e => e.Status) + .ConvertFromDatabaseUsing() + .ConvertFromDatabaseUsing(); + } + } + + private sealed class DuplicateWriteConversionMap : EntityMap + { + public DuplicateWriteConversionMap() + { + Map(e => e.Status) + .ConvertToDatabaseUsing() + .ConvertToDatabaseUsing(); + } + } + + private class ConversionBaseEntity + { + public AccountStatus Status { get; set; } + } + + private sealed class ConversionDerivedEntity : ConversionBaseEntity + { + public string Name { get; set; } + } + + private sealed class ConversionBaseMap : EntityMap + { + public ConversionBaseMap() + { + Map(e => e.Status).ConvertFromDatabaseUsing(); + } + } + + private sealed class ConversionDerivedMap : EntityMap + { + public ConversionDerivedMap() + { + IncludeBase(); + Map(e => e.Name).ToColumn("name"); + } + } + + private sealed class ConversionDerivedOverrideMap : EntityMap + { + public ConversionDerivedOverrideMap() + { + IncludeBase(); + Map(e => e.Status).ConvertFromDatabaseUsing(); + } + } + + private sealed class LegacyConversionProfile : IMappingProfile + { + } + + private sealed class ProfileConversionEntity + { + public AccountStatus Status { get; set; } + } + + private sealed class DefaultProfileConversionMap : EntityMap + { + public DefaultProfileConversionMap() + { + Map(e => e.Status).ToColumn("status"); + } + } + + private sealed class LegacyProfileConversionMap : + EntityMap, + IProfileMap + { + public LegacyProfileConversionMap() + { + Map(e => e.Status) + .ToColumn("legacy_status") + .ConvertFromDatabaseUsing(); + } + } + + private sealed class SecondLegacyProfileConversionMap : + EntityMap, + IProfileMap + { + public SecondLegacyProfileConversionMap() + { + Map(e => e.Status) + .ToColumn("legacy_status_code") + .ConvertFromDatabaseUsing(); + } + } + + private sealed class StatusReadConverter : IReadPropertyConverter + { + public AccountStatus ConvertFromDatabase(string value) + { + return AccountStatus.Active; + } + } + + private sealed class AlternateStatusReadConverter : IReadPropertyConverter + { + public AccountStatus ConvertFromDatabase(string value) + { + return AccountStatus.Inactive; + } + } + + private sealed class CountingReadConverter : IReadPropertyConverter + { + public CountingReadConverter() + { + Created++; + } + + public static int Created { get; set; } + + public AccountStatus ConvertFromDatabase(string value) + { + return AccountStatus.Active; + } + } + + private sealed class IntReadConverter : IReadPropertyConverter + { + public int ConvertFromDatabase(string value) + { + return 1; + } + } + + private sealed class StatusWriteConverter : IWritePropertyConverter + { + public string ConvertToDatabase(AccountStatus value) + { + return value.ToString(); + } + } + + private sealed class AlternateStatusWriteConverter : IWritePropertyConverter + { + public string ConvertToDatabase(AccountStatus value) + { + return value.ToString(); + } + } + + private sealed class StatusTextConverter : IPropertyConverter + { + public AccountStatus ConvertFromDatabase(string value) + { + return AccountStatus.Active; + } + + public string ConvertToDatabase(AccountStatus value) + { + return value.ToString(); + } + } + + private sealed class NoDirectionConverter + { + } + } +} From 99f89dc51dde7aa91097c9324f2a96be2fcce752 Mon Sep 17 00:00:00 2001 From: Rodrigo de Oliveira Date: Tue, 28 Jul 2026 17:09:30 -0300 Subject: [PATCH 24/49] feat(materialization): apply property read converters --- .sdd/etapa-10/04-runtime-conversion.md | 166 +++++ .sdd/etapa-10/05-performance-baseline.md | 69 ++ .sdd/etapa-10/DECISIONS.md | 33 + .sdd/etapa-10/STATUS.md | 66 +- README.md | 31 +- .../Dapper.FluentMap.Benchmarks/Program.cs | 151 ++++- .../NestedMaterializationPlan.cs | 233 ++++++- .../RuntimeReadConversionTests.cs | 609 ++++++++++++++++++ 8 files changed, 1314 insertions(+), 44 deletions(-) create mode 100644 .sdd/etapa-10/04-runtime-conversion.md create mode 100644 .sdd/etapa-10/05-performance-baseline.md create mode 100644 test/Dapper.FluentMap.Tests/RuntimeReadConversionTests.cs diff --git a/.sdd/etapa-10/04-runtime-conversion.md b/.sdd/etapa-10/04-runtime-conversion.md new file mode 100644 index 0000000..9c89a0c --- /dev/null +++ b/.sdd/etapa-10/04-runtime-conversion.md @@ -0,0 +1,166 @@ +# Etapa 10 - Runtime Read Conversion + +## Ponto de execucao + +Read converters por propriedade executam somente no materializer comum de +`QueryMapped*`, `ReadMapped*`, `QueryMultipleMapped`, streaming unbuffered +sincrono e streaming unbuffered assincrono. + +O ponto exato e a folha terminal de `NestedMaterializationPlan`: + +```text +IDataRecord.GetValue(columnOrdinal) + -> NestedLeaf.GetValue + -> null/DBNull handling + -> property read converter, se configurado para o member path efetivo + -> Dapper TypeHandler, se nao houver property converter + -> conversao padrao do FluentMap + -> setter ou parametro de construtor +``` + +`MappedRowMaterializer` continua sendo o unico dispatch das APIs mapeadas: +generated materializer quando valido, senao runtime fallback. Descriptors +generated ainda sao rejeitados quando o mapping efetivo possui read converter, +evitando materializer gerado sem conversao. + +## Precedencia + +Para runtime `QueryMapped*`: + +```text +null/DBNull handling + -> property read converter + -> Dapper TypeHandler + -> default FluentMap conversion +``` + +O property converter recebe o valor CLR vindo de `IDataRecord.GetValue`, +normalizado apenas para o `TDatabase` declarado quando a conversao CLR padrao +e necessaria. `TypeHandler` nao e aplicado antes nem depois do +property converter da mesma folha. + +Sem read converter configurado, o comportamento anterior permanece: o runtime +consulta `TypeHandler` e, se nao houver handler, aplica cast direto, +enum, `Guid` de string e `Convert.ChangeType(..., InvariantCulture)`. + +## Null semantics + +`null` e `DBNull.Value` nao sao enviados ao converter por default. + +- `Nullable` recebe `null`. +- reference types recebem `null`. +- value types nao nullable preservam o comportamento historico e recebem + `default(T)`. +- subarvores aninhadas continuam usando `HasNonNullValue`: se todos os valores + da subarvore sao `DBNull`, nenhuma folha nem converter e executado e a + subarvore fica `null` quando atribuivel. + +Se um converter retornar `null` para target value type nao nullable, a +materializacao falha com `FluentMapConfigurationException` e inner exception +preservando a causa local. + +## Primitive conversion e enums + +Sem property converter, a conversao padrao existente continua: + +- valor ja atribuivel: retorno direto; +- enum: parse de string ou `Enum.ToObject`; +- `Guid`: parse de string; +- demais casos: `Convert.ChangeType` com cultura invariante. + +Com property converter, essa conversao padrao so pode ser usada para ajustar o +valor bruto ao `TDatabase` declarado do converter. O resultado do converter e +tratado como o valor da propriedade e nao passa por `TypeHandler`. + +## Value Objects + +Value Objects escalares podem usar property converter quando a representacao e +local a uma propriedade/profile: + +```csharp +Map(x => x.Cpf) + .ToColumn("cpf") + .ConvertFromDatabaseUsing(); +``` + +Sem property converter, `TypeHandler` segue sendo o mecanismo +global recomendado para Value Objects escalares. + +Value Objects por componentes continuam convertendo folhas terminais antes de +invocar construtores publicos compativeis: + +```csharp +Map(x => x.Cpf.Number) + .ToColumn("cpf") + .ConvertFromDatabaseUsing(); +``` + +## Constructor parameters + +Folhas ligadas a parametros de construtor sao convertidas antes da montagem do +array de argumentos. A selecao do construtor continua baseada no tipo do member +path/propriedade, nao no `TDatabase` do converter. + +Falhas de converter em parametros de construtor sao encapsuladas antes da +invocacao do construtor. Falhas de dominio do proprio construtor continuam +encapsuladas pelo bloco de materializacao de construtor existente. + +## Nested properties + +Converters sao associados ao member path efetivo do property map. Um converter +configurado em: + +```csharp +Map(x => x.BillingAddress.ZipCode) +``` + +nao se aplica a: + +```csharp +Map(x => x.ShippingAddress.ZipCode) +``` + +mesmo quando o tipo terminal e o nome da propriedade sao iguais. + +## Profiles + +Profiles usam o property map efetivo do profile selecionado: + +```text +QueryMapped() + -> default entity map + +QueryMapped() + -> profile map LegacyProfile +``` + +Conversores do map default nao vazam para profiles. Reuso deve ser explicito +por `IncludeBase()` ou por configuracao direta no profile. + +## Exception wrapping + +Falhas de read converter geram `FluentMapConfigurationException` com inner +exception preservada. A mensagem inclui, quando disponivel: + +- entity type; +- profile type; +- member path; +- column; +- converter type; +- source CLR type; +- converter database type; +- converter property type; +- target CLR type. + +O texto exato e diagnostico, nao contrato publico. O tipo da excecao e a inner +exception preservada sao parte do comportamento esperado. + +## Fallback + +APIs Dapper puras (`Query()`, `QuerySingle()` etc.) continuam fora do +escopo property-scoped do FluentMap. Elas usam Dapper type map para nomes e os +mecanismos de conversao do Dapper/provider. + +Generated materializers ainda nao executam property converters neste incremento. +Quando um generated descriptor nao declara essa metadata, o registry nao o +seleciona e o runtime materializer assume. diff --git a/.sdd/etapa-10/05-performance-baseline.md b/.sdd/etapa-10/05-performance-baseline.md new file mode 100644 index 0000000..4311a44 --- /dev/null +++ b/.sdd/etapa-10/05-performance-baseline.md @@ -0,0 +1,69 @@ +# Etapa 10 - Performance Baseline + +## Objetivo + +Medir o custo inicial do runtime materializer apos adicionar read converters por +propriedade, comparando caminhos equivalentes que usam runtime fallback: + +- sem converter; +- converter simples de propriedade; +- Dapper `TypeHandler`; +- property converter para Value Object escalar coexistindo com `TypeHandler`. + +Os benchmarks usam 1000 linhas em SQLite in-memory e selecionam colunas em ordem +diferente do descriptor gerado para forcar o runtime fallback. + +## Benchmark adicionado + +Projeto: + +```text +benchmarks/Dapper.FluentMap.Benchmarks/Dapper.FluentMap.Benchmarks.csproj +``` + +Metodos: + +```text +MaterializationSteadyStateBenchmarks.QueryMappedRuntimeNoConverter +MaterializationSteadyStateBenchmarks.QueryMappedRuntimeSimpleConverter +MaterializationSteadyStateBenchmarks.QueryMappedRuntimeTypeHandler +MaterializationSteadyStateBenchmarks.QueryMappedRuntimePropertyConverter +``` + +## Execucao local + +Comando executado em 2026-07-28: + +```powershell +dotnet run --configuration Release --project .\benchmarks\Dapper.FluentMap.Benchmarks\Dapper.FluentMap.Benchmarks.csproj -- --filter "*MaterializationSteadyStateBenchmarks.QueryMappedRuntime*" --job Dry --warmupCount 1 --minIterationCount 1 --maxIterationCount 2 +``` + +Ambiente reportado: + +```text +BenchmarkDotNet v0.15.8 +Windows 11 25H2 +.NET SDK 10.0.302 +.NET Runtime 10.0.10 +Intel Core i5-1145G7 +``` + +Resultado observado: + +| Method | Mean | Allocated | +|---|---:|---:| +| QueryMappedRuntimePropertyConverter | 1.334 ms | 165.98 KB | +| QueryMappedRuntimeTypeHandler | 1.606 ms | 165.98 KB | +| QueryMappedRuntimeSimpleConverter | 1.676 ms | 189.43 KB | +| QueryMappedRuntimeNoConverter | 2.768 ms | 142.55 KB | + +## Interpretacao + +Esta execucao e uma baseline curta representativa, nao uma conclusao estatistica +final. O BenchmarkDotNet alertou que os tempos de iteracao ficaram abaixo de +100 ms, portanto uma comparacao formal deve aumentar operacoes/iteracoes. + +O resultado confirma que o caminho novo compila e executa sem regressao obvia de +alocacao por linha. A diferenca favoravel dos converters nesta execucao parece +mais ligada ao shape simples e ao custo da conversao padrao sem converter do que +a uma otimizacao deliberada. Nao foi feita otimizacao prematura antes da medida. diff --git a/.sdd/etapa-10/DECISIONS.md b/.sdd/etapa-10/DECISIONS.md index fe1d801..76633e9 100644 --- a/.sdd/etapa-10/DECISIONS.md +++ b/.sdd/etapa-10/DECISIONS.md @@ -308,3 +308,36 @@ leitura. O projeto passa a conseguir validar e inspecionar converters por propriedade, profile e heranca, mantendo comportamento de valor inalterado ate a etapa que implementar execucao. A API e aditiva e preserva `IPropertyMap`. + +## ADR-12 - Prompt 10.3 runtime read conversion + +### Contexto + +O Prompt 10.3 pediu que o runtime materializer aplicasse read converters +configurados por propriedade sem duplicar indevidamente `TypeHandler` do +Dapper e sem espalhar logica pelas APIs `QueryMapped*`, `ReadMapped*` e +streaming. + +### Decisao + +Read converters por propriedade executam em `NestedMaterializationPlan`, na +folha terminal que le `IDataRecord.GetValue`. A precedencia efetiva e: + +```text +null/DBNull handling + -> property read converter + -> Dapper TypeHandler + -> FluentMap default conversion +``` + +Quando um property converter existe para a folha, o `TypeHandler` nao +e chamado para aquela propriedade. Sem converter, o caminho antigo com +`TypeHandler` e conversao padrao permanece. + +### Consequencias + +Todas as APIs que usam `MappedRowMaterializer` compartilham a mesma semantica: +`QueryMapped`, `QueryMultipleMapped`/`ReadMapped`, unbuffered sincrono e +streaming assincrono. Generated materializers continuam caindo para runtime +fallback quando o mapping efetivo possui read converter. Escrita/Dommel e +execucao generated de converters permanecem incrementos separados. diff --git a/.sdd/etapa-10/STATUS.md b/.sdd/etapa-10/STATUS.md index 910fc70..a75fc99 100644 --- a/.sdd/etapa-10/STATUS.md +++ b/.sdd/etapa-10/STATUS.md @@ -52,22 +52,37 @@ tipo e abrindo espaco para conversao por propriedade, map e profile. - Adicionados testes de metadata/fluent API/read-only/write-only/bidirecional, delegates, lifetime por property map, heranca, profile, invalid types, duplicidade, nullability, profile collision e generated fallback defensivo. +- Criado `.sdd/etapa-10/04-runtime-conversion.md`. +- Implementada execucao de read converters por propriedade no runtime + materializer comum (`NestedMaterializationPlan`). +- Preservada precedencia: + `null/DBNull -> property read converter -> Dapper TypeHandler -> default conversion`. +- Garantido que property converter configurado nao recebe valor ja convertido + por `TypeHandler` e que `TypeHandler` nao roda depois + do property converter da mesma folha. +- Mantida a selecao generated-then-runtime existente; generated descriptors + com read converter continuam recusados e caem para runtime fallback. +- Adicionados testes de execucao para scalar conversion, nullable, null, enum, + nested member path, constructor parameter, Value Object escalar, profile, + exception wrapping, coexistencia com TypeHandler, unbuffered e async + streaming. +- Atualizado README para documentar read conversion em runtime mapped. +- Adicionados benchmarks especificos para no converter, simple converter, + TypeHandler e property converter. +- Criado `.sdd/etapa-10/05-performance-baseline.md`. ## Em andamento -Execucao real de converters em runtime materializer, generated materializer e -write/Dommel permanece adiada para incrementos seguintes. +Generated materializer read conversion e write/Dommel conversion permanecem +adiadas para incrementos seguintes. ## Proximos passos -1. Adicionar read conversion no runtime materializer com testes de regressao. -2. Evoluir generated read conversion ou fallback seguro quando houver converter. -3. Cobrir nested leaves e Value Objects com execucao real de converter. -4. Investigar e implementar write conversion/Dommel somente apos definir hook +1. Evoluir generated read conversion ou fallback seguro quando houver converter. +2. Investigar e implementar write conversion/Dommel somente apos definir hook de parametros por propriedade. -5. Evoluir diagnostics/analyzers para reconhecer `Convert...`. -6. Medir performance e documentar API publica no README quando a execucao for - ativada. +3. Evoluir diagnostics/analyzers para reconhecer `Convert...`. +4. Aumentar benchmark formal quando houver decisao de otimizacao. ## Decisoes relevantes @@ -87,6 +102,8 @@ write/Dommel permanece adiada para incrementos seguintes. deve depender de ativacao reflection-only. - Prompt 10.2 decidiu implementar somente contracts/metadata/fluent API e diagnostics, mantendo execucao de conversores para incremento posterior. +- Prompt 10.3 executa read converters no runtime materializer comum e mantem + generated/write conversion fora do escopo. ## APIs implementadas no Prompt 10.2 @@ -140,9 +157,9 @@ public interface IPropertyConverter : introduzida cedo demais. - Converter por reflection precisa de anotacoes de trimming e estrategia AOT. - Caches atuais assumem configuracao efetivamente imutavel apos registro. -- Converter metadata ja existe, mas `QueryMapped*` ainda nao executa os - conversores. Isso e intencional no Prompt 10.2 para evitar divergencia - runtime/generated antes da proxima implementacao. +- Converter metadata ja existe, mas generated materializers ainda nao executam + read converters. Isso e intencional no Prompt 10.3 para evitar divergencia + runtime/generated antes da implementacao generated. - `Convert...Using()` valida contrato por reflection de interfaces em configuration time; overloads por instancia/delegate oferecem caminho mais favoravel a AOT. @@ -170,6 +187,25 @@ public interface IPropertyConverter : sucesso, pacote criado em `artifacts/packages/Dapper.FluentMap.2.0.0.nupkg`; warning conhecido `NU5125` sobre `licenseUrl` depreciado. +## Validacao do Prompt 10.3 + +- `dotnet test test\Dapper.FluentMap.Tests\Dapper.FluentMap.Tests.csproj --configuration Release --filter FullyQualifiedName~RuntimeReadConversionTests`: + sucesso, 11 testes aprovados. +- `dotnet restore .\Dapper.FluentMap.sln`: sucesso. +- `dotnet build .\Dapper.FluentMap.sln --configuration Release --no-restore`: + sucesso, 0 warnings, 0 errors. +- `dotnet test .\Dapper.FluentMap.sln --configuration Release --no-build`: + sucesso, 385 testes aprovados no total. +- `dotnet pack .\src\Dapper.FluentMap\Dapper.FluentMap.csproj --configuration Release --no-build --output .\artifacts\packages`: + sucesso, pacote criado em `artifacts/packages/Dapper.FluentMap.2.0.0.nupkg`; + warning conhecido `NU5125` sobre `licenseUrl` depreciado. +- `dotnet run --configuration Release --project .\benchmarks\Dapper.FluentMap.Benchmarks\Dapper.FluentMap.Benchmarks.csproj -- --filter "*MaterializationSteadyStateBenchmarks.QueryMappedRuntime*" --job Dry --warmupCount 1 --minIterationCount 1 --maxIterationCount 2`: + sucesso. Resultado observado: no converter 2.768 ms / 142.55 KB, simple + converter 1.676 ms / 189.43 KB, TypeHandler 1.606 ms / 165.98 KB, property + converter 1.334 ms / 165.98 KB. BenchmarkDotNet alertou que os tempos de + iteracao ficaram abaixo de 100 ms; usar como baseline curta, nao como + conclusao estatistica final. + ## Interacao com Dapper TypeHandler Precedencia proposta para `QueryMapped*`: @@ -201,6 +237,8 @@ connection.Query() - `.sdd/etapa-10/01-conversion-landscape.md` - `.sdd/etapa-10/02-property-conversion-spec.md` - `.sdd/etapa-10/03-converter-contract-design.md` +- `.sdd/etapa-10/04-runtime-conversion.md` +- `.sdd/etapa-10/05-performance-baseline.md` - `.sdd/etapa-10/DECISIONS.md` - `.sdd/etapa-10/STATUS.md` - `src/Dapper.FluentMap/Materialization/NestedMaterializationPlan.cs` @@ -218,7 +256,9 @@ connection.Query() - `test/Dapper.FluentMap.Tests/ValueObjectMaterializationTests.cs` - `test/Dapper.FluentMap.Tests/AdvancedQueryHardeningTests.cs` - `test/Dapper.FluentMap.Tests/PropertyConversionMetadataTests.cs` +- `test/Dapper.FluentMap.Tests/RuntimeReadConversionTests.cs` +- `benchmarks/Dapper.FluentMap.Benchmarks/Program.cs` ## Ultimo prompt executado -Ultimo prompt executado: 10.2 +Ultimo prompt executado: 10.3 diff --git a/README.md b/README.md index 0cd896d..e63ae1e 100644 --- a/README.md +++ b/README.md @@ -159,10 +159,10 @@ Map(product => product.Total) Computed properties participate in reads and are excluded from generated `INSERT` and `UPDATE` metadata. -### Property Conversion Metadata +### Property Read Conversion -Property converters can be attached to a mapping as metadata for future -read/write conversion paths: +Property read converters can be attached to a mapping when a column value needs +property-specific conversion during FluentMap-controlled materialization: ```csharp Map(product => product.Status) @@ -170,10 +170,11 @@ Map(product => product.Status) .ConvertToDatabaseUsing(); ``` -The current increment stores and validates converter metadata per property, -including profile and inherited mappings. It does not yet execute those -converters during Dapper queries, `QueryMapped*` materialization or Dommel -write operations. +`QueryMapped*`, `ReadMapped*`, `QueryMultipleMapped` and unbuffered streaming +apply read converters in the runtime materializer before falling back to a +Dapper `TypeHandler` or FluentMap's default conversion. Normal Dapper +queries (`Query()`) and Dommel write operations are unchanged; write +converter metadata is stored for a later parameter-conversion increment. Inherited explicit mappings can be included when the derived entity should reuse a base entity map: @@ -770,10 +771,11 @@ Map(product => product.Total) Propriedades computed participam de leituras e são excluídas da metadata de `INSERT` e `UPDATE` gerados. -### Metadata de Conversao por Propriedade +### Conversao de Leitura por Propriedade -Conversores podem ser anexados a um mapping como metadata para caminhos futuros -de conversao de leitura/escrita: +Conversores de leitura podem ser anexados a um mapping quando um valor de +coluna precisa de conversao especifica da propriedade durante materializacao +controlada pelo FluentMap: ```csharp Map(product => product.Status) @@ -781,9 +783,12 @@ Map(product => product.Status) .ConvertToDatabaseUsing(); ``` -O incremento atual armazena e valida metadata de conversor por propriedade, -incluindo profiles e mappings herdados. Ele ainda nao executa esses conversores -em consultas Dapper, materializacao `QueryMapped*` ou escritas Dommel. +`QueryMapped*`, `ReadMapped*`, `QueryMultipleMapped` e streaming unbuffered +aplicam conversores de leitura no materializador de runtime antes de cair para +um `TypeHandler` do Dapper ou para a conversao padrao do FluentMap. +Consultas Dapper normais (`Query()`) e escritas Dommel nao mudam; metadata de +write converter fica armazenada para um incremento futuro de conversao de +parametros. Mapeamentos explícitos herdados podem ser incluídos quando a entidade derivada deve reutilizar um map da entidade base: diff --git a/benchmarks/Dapper.FluentMap.Benchmarks/Program.cs b/benchmarks/Dapper.FluentMap.Benchmarks/Program.cs index 1397788..e0386cf 100644 --- a/benchmarks/Dapper.FluentMap.Benchmarks/Program.cs +++ b/benchmarks/Dapper.FluentMap.Benchmarks/Program.cs @@ -35,6 +35,7 @@ public async Task GlobalSetup() { SQLitePCL.Batteries_V2.Init(); ResetPublicFluentState(); + SqlMapper.AddTypeHandler(new BenchmarkHandledCodeTypeHandler()); FluentMapper.Initialize(configuration => { @@ -61,6 +62,10 @@ public async Task GlobalSetup() DapperQueryMultipleBuffered(); QueryMultipleMappedSimple(); QueryMultipleMappedSimpleRuntimeFallback(); + QueryMappedRuntimeNoConverter(); + QueryMappedRuntimeSimpleConverter(); + QueryMappedRuntimeTypeHandler(); + QueryMappedRuntimePropertyConverter(); } [GlobalCleanup] @@ -223,6 +228,38 @@ public int QueryMultipleMappedSimpleRuntimeFallback() multi.ReadMapped().Count(); } + [Benchmark] + public int QueryMappedRuntimeNoConverter() + { + return _connection.QueryMapped( + "SELECT Name AS full_name, Id AS customer_id FROM BenchmarkRows;") + .Count(); + } + + [Benchmark] + public int QueryMappedRuntimeSimpleConverter() + { + return _connection.QueryMapped( + "SELECT Name AS full_name, Id AS customer_id FROM BenchmarkRows;") + .Count(); + } + + [Benchmark] + public int QueryMappedRuntimeTypeHandler() + { + return _connection.QueryMapped( + "SELECT Cpf AS code, Id AS customer_id FROM BenchmarkRows;") + .Count(); + } + + [Benchmark] + public int QueryMappedRuntimePropertyConverter() + { + return _connection.QueryMapped( + "SELECT Cpf AS code, Id AS customer_id FROM BenchmarkRows;") + .Count(); + } + private static SqliteConnection OpenPopulatedConnection() { var connection = new SqliteConnection("Data Source=:memory:"); @@ -284,6 +321,7 @@ private static void ResetPublicFluentState() { FluentMapper.EntityMaps.Clear(); FluentMapper.TypeConventions.Clear(); + SqlMapper.ResetTypeHandlers(); foreach (var type in BenchmarkTypes.AllBenchmarkTypes) { @@ -444,7 +482,11 @@ internal static class BenchmarkTypes typeof(QueryMappedSimpleCustomer), typeof(ImmutableCustomer), typeof(NestedCustomer), - typeof(ValueObjectCustomer) + typeof(ValueObjectCustomer), + typeof(RuntimeNoConverterCustomer), + typeof(RuntimeSimpleConverterCustomer), + typeof(RuntimeTypeHandlerCustomer), + typeof(RuntimePropertyConverterCustomer) }; internal static readonly Type[] AllColdTypes = @@ -636,6 +678,113 @@ public ValueObjectCustomerMap() } } +public sealed class RuntimeNoConverterCustomer +{ + public int Id { get; set; } + + public string FullName { get; set; } = string.Empty; +} + +public sealed class RuntimeNoConverterCustomerMap : EntityMap +{ + public RuntimeNoConverterCustomerMap() + { + Map(customer => customer.Id).ToColumn("customer_id"); + Map(customer => customer.FullName).ToColumn("full_name"); + } +} + +public sealed class RuntimeSimpleConverterCustomer +{ + public int Id { get; set; } + + public string FullName { get; set; } = string.Empty; +} + +public sealed class RuntimeSimpleConverterCustomerMap : EntityMap +{ + public RuntimeSimpleConverterCustomerMap() + { + Map(customer => customer.Id).ToColumn("customer_id"); + Map(customer => customer.FullName) + .ToColumn("full_name") + .ConvertFromDatabaseUsing(); + } +} + +public sealed class BenchmarkUpperNameConverter : IReadPropertyConverter +{ + public string ConvertFromDatabase(string value) + { + return value.ToUpperInvariant(); + } +} + +public sealed class RuntimeTypeHandlerCustomer +{ + public int Id { get; set; } + + public BenchmarkHandledCode Code { get; set; } = null!; +} + +public sealed class RuntimeTypeHandlerCustomerMap : EntityMap +{ + public RuntimeTypeHandlerCustomerMap() + { + Map(customer => customer.Id).ToColumn("customer_id"); + Map(customer => customer.Code).ToColumn("code"); + } +} + +public sealed class RuntimePropertyConverterCustomer +{ + public int Id { get; set; } + + public BenchmarkHandledCode Code { get; set; } = null!; +} + +public sealed class RuntimePropertyConverterCustomerMap : EntityMap +{ + public RuntimePropertyConverterCustomerMap() + { + Map(customer => customer.Id).ToColumn("customer_id"); + Map(customer => customer.Code) + .ToColumn("code") + .ConvertFromDatabaseUsing(); + } +} + +public sealed class BenchmarkHandledCode +{ + public BenchmarkHandledCode(string value) + { + Value = value; + } + + public string Value { get; } +} + +public sealed class BenchmarkHandledCodeTypeHandler : SqlMapper.TypeHandler +{ + public override BenchmarkHandledCode Parse(object value) + { + return new BenchmarkHandledCode((string)value); + } + + public override void SetValue(IDbDataParameter parameter, BenchmarkHandledCode? value) + { + parameter.Value = value == null ? DBNull.Value : value.Value; + } +} + +public sealed class BenchmarkHandledCodeConverter : IReadPropertyConverter +{ + public BenchmarkHandledCode ConvertFromDatabase(string value) + { + return new BenchmarkHandledCode(value); + } +} + public sealed class ColdPureCustomer { public int Id { get; set; } diff --git a/src/Dapper.FluentMap/Materialization/NestedMaterializationPlan.cs b/src/Dapper.FluentMap/Materialization/NestedMaterializationPlan.cs index 0365fcb..4e904ba 100644 --- a/src/Dapper.FluentMap/Materialization/NestedMaterializationPlan.cs +++ b/src/Dapper.FluentMap/Materialization/NestedMaterializationPlan.cs @@ -51,7 +51,13 @@ internal static NestedMaterializationPlan Create(Type entityType, Type profileTy } var memberPath = PropertyMapIdentity.GetMemberPath(fluentMap); - rootNode.AddPropertyPath(memberPath, i, columnName); + rootNode.AddPropertyPath( + memberPath, + i, + columnName, + entityType, + profileType, + PropertyMapConversion.GetConversion(fluentMap)); continue; } @@ -63,11 +69,22 @@ internal static NestedMaterializationPlan Create(Type entityType, Type profileTy if (defaultMember.Property != null) { - rootNode.AddRootProperty(defaultMember.Property, i, columnName); + rootNode.AddRootProperty( + defaultMember.Property, + i, + columnName, + entityType, + profileType, + PropertyConversionMetadata.Default); } else if (defaultMember.Field != null) { - rootNode.AddRootField(defaultMember.Field, i, columnName); + rootNode.AddRootField( + defaultMember.Field, + i, + columnName, + entityType, + profileType); } } @@ -146,7 +163,29 @@ private static Action CreateFieldSetter(FieldInfo field) return Expression.Lambda>(body, target, value).Compile(); } - private static Func CreateConverter(Type targetType) + private static Func CreateConverter( + Type entityType, + Type profileType, + Type targetType, + string memberPath, + string columnName, + PropertyConversionMetadata conversion) + { + if (conversion != null && conversion.HasReadConverter) + { + return CreatePropertyReadConverter( + entityType, + profileType, + targetType, + memberPath, + columnName, + conversion.ReadConverter); + } + + return CreateDefaultConverter(targetType); + } + + private static Func CreateDefaultConverter(Type targetType) { var conversionType = Nullable.GetUnderlyingType(targetType) ?? targetType; if (DapperTypeHandlerAdapter.HasTypeHandler(conversionType)) @@ -157,6 +196,113 @@ private static Func CreateConverter(Type targetType) return value => ConvertValue(value, targetType); } + private static Func CreatePropertyReadConverter( + Type entityType, + Type profileType, + Type targetType, + string memberPath, + string columnName, + PropertyConverterMetadata converter) + { + var invokeConverter = CreateReadConverterInvoker(converter); + + return value => + { + if (value == null || value == DBNull.Value) + { + return GetDefaultValue(targetType); + } + + try + { + var converterInput = ConvertConverterInput(value, converter.DatabaseType); + var converted = invokeConverter(converterInput); + if (converted == null && !CanAssignNull(targetType)) + { + throw new InvalidOperationException( + $"Read converter '{converter.ConverterType.FullName}' returned null for non-nullable target type '{targetType.FullName}'."); + } + + return converted; + } + catch (Exception exception) when (!(exception is FluentMapConfigurationException)) + { + throw CreateReadConverterException( + entityType, + profileType, + memberPath, + columnName, + converter, + value, + targetType, + exception); + } + }; + } + + private static Func CreateReadConverterInvoker(PropertyConverterMetadata converter) + { + var value = Expression.Parameter(typeof(object), "value"); + var converterValue = Expression.Constant(converter.Converter); + var input = Expression.Convert(value, converter.DatabaseType); + Expression call; + + if (converter.Converter is Delegate) + { + var delegateType = typeof(ReadPropertyConverter<,>).MakeGenericType( + converter.DatabaseType, + converter.PropertyType); + call = Expression.Invoke(Expression.Convert(converterValue, delegateType), input); + } + else + { + var interfaceType = typeof(IReadPropertyConverter<,>).MakeGenericType( + converter.DatabaseType, + converter.PropertyType); + call = Expression.Call( + Expression.Convert(converterValue, interfaceType), + interfaceType.GetMethod(nameof(IReadPropertyConverter.ConvertFromDatabase)), + input); + } + + return Expression.Lambda>( + Expression.Convert(call, typeof(object)), + value).Compile(); + } + + private static object ConvertConverterInput(object value, Type databaseType) + { + var conversionType = Nullable.GetUnderlyingType(databaseType) ?? databaseType; + if (conversionType == typeof(object) || conversionType.IsInstanceOfType(value)) + { + return value; + } + + return ConvertValue(value, databaseType); + } + + private static FluentMapConfigurationException CreateReadConverterException( + Type entityType, + Type profileType, + string memberPath, + string columnName, + PropertyConverterMetadata converter, + object sourceValue, + Type targetType, + Exception innerException) + { + var profileContext = profileType == null + ? string.Empty + : $" Profile: '{FormatType(profileType)}'."; + var sourceType = sourceValue == null || sourceValue == DBNull.Value + ? null + : sourceValue.GetType(); + + return new FluentMapConfigurationException( + $"Read converter failed for entity '{FormatType(entityType)}'.{profileContext} Member path: '{memberPath}'. Column: '{columnName}'. Converter: '{FormatType(converter.ConverterType)}'. Source type: '{FormatType(sourceType)}'. Converter database type: '{FormatType(converter.DatabaseType)}'. Converter property type: '{FormatType(converter.PropertyType)}'. Target type: '{FormatType(targetType)}'. See the inner exception for the converter failure.", + innerException); + } + private static object ConvertValue(object value, Type targetType) { if (value == null || value == DBNull.Value) @@ -287,12 +433,18 @@ internal static MaterializationNode Root(Type type) return new MaterializationNode(type, null, type.Name, isRoot: true); } - internal void AddPropertyPath(MemberPath memberPath, int columnIndex, string columnName) + internal void AddPropertyPath( + MemberPath memberPath, + int columnIndex, + string columnName, + Type entityType, + Type profileType, + PropertyConversionMetadata conversion) { var properties = memberPath.Properties; if (!memberPath.IsNested) { - AddRootProperty(properties[0], columnIndex, columnName); + AddRootProperty(properties[0], columnIndex, columnName, entityType, profileType, conversion); return; } @@ -302,17 +454,42 @@ internal void AddPropertyPath(MemberPath memberPath, int columnIndex, string col node = node.FindOrAddChild(properties[i]); } - node._leaves.Add(NestedLeaf.ForProperty(properties[properties.Count - 1], columnIndex, columnName, memberPath.ToString())); + node._leaves.Add(NestedLeaf.ForProperty( + properties[properties.Count - 1], + columnIndex, + columnName, + memberPath.ToString(), + entityType, + profileType, + conversion)); } - internal void AddRootProperty(PropertyInfo property, int columnIndex, string columnName) + internal void AddRootProperty( + PropertyInfo property, + int columnIndex, + string columnName, + Type entityType, + Type profileType, + PropertyConversionMetadata conversion) { - _leaves.Add(NestedLeaf.ForProperty(property, columnIndex, columnName, property.Name)); + _leaves.Add(NestedLeaf.ForProperty( + property, + columnIndex, + columnName, + property.Name, + entityType, + profileType, + conversion)); } - internal void AddRootField(FieldInfo field, int columnIndex, string columnName) + internal void AddRootField( + FieldInfo field, + int columnIndex, + string columnName, + Type entityType, + Type profileType) { - _leaves.Add(NestedLeaf.ForField(field, columnIndex, columnName, field.Name)); + _leaves.Add(NestedLeaf.ForField(field, columnIndex, columnName, field.Name, entityType, profileType)); } internal void Seal(Type entityType) @@ -571,7 +748,10 @@ private NestedLeaf( string columnName, string memberPath, Type targetType, - Action setter) + Action setter, + Type entityType, + Type profileType, + PropertyConversionMetadata conversion) { Property = property; Field = field; @@ -580,7 +760,7 @@ private NestedLeaf( MemberPath = memberPath; TargetType = targetType; _setter = setter; - _converter = CreateConverter(targetType); + _converter = CreateConverter(entityType, profileType, targetType, memberPath, columnName, conversion); } internal PropertyInfo Property { get; } @@ -597,7 +777,14 @@ private NestedLeaf( internal bool CanAssign => _setter != null; - internal static NestedLeaf ForProperty(PropertyInfo property, int columnIndex, string columnName, string memberPath) + internal static NestedLeaf ForProperty( + PropertyInfo property, + int columnIndex, + string columnName, + string memberPath, + Type entityType, + Type profileType, + PropertyConversionMetadata conversion) { return new NestedLeaf( property, @@ -606,10 +793,19 @@ internal static NestedLeaf ForProperty(PropertyInfo property, int columnIndex, s columnName, memberPath, property.PropertyType, - CreatePropertySetter(property)); + CreatePropertySetter(property), + entityType, + profileType, + conversion ?? PropertyConversionMetadata.Default); } - internal static NestedLeaf ForField(FieldInfo field, int columnIndex, string columnName, string memberPath) + internal static NestedLeaf ForField( + FieldInfo field, + int columnIndex, + string columnName, + string memberPath, + Type entityType, + Type profileType) { return new NestedLeaf( null, @@ -618,7 +814,10 @@ internal static NestedLeaf ForField(FieldInfo field, int columnIndex, string col columnName, memberPath, field.FieldType, - CreateFieldSetter(field)); + CreateFieldSetter(field), + entityType, + profileType, + PropertyConversionMetadata.Default); } internal object GetValue(IDataRecord record) diff --git a/test/Dapper.FluentMap.Tests/RuntimeReadConversionTests.cs b/test/Dapper.FluentMap.Tests/RuntimeReadConversionTests.cs new file mode 100644 index 0000000..133eac1 --- /dev/null +++ b/test/Dapper.FluentMap.Tests/RuntimeReadConversionTests.cs @@ -0,0 +1,609 @@ +using System; +using System.Collections.Generic; +using System.Data; +using System.Linq; +using System.Threading.Tasks; +using Dapper; +using Dapper.FluentMap.Mapping; +using Microsoft.Data.Sqlite; +using Xunit; + +namespace Dapper.FluentMap.Tests +{ + public class RuntimeReadConversionTests + { + [Fact] + [Trait("Category", "Integration")] + public void QueryMappedShouldApplyScalarNullableAndEnumReadConverters() + { + PreTest(typeof(ConversionCustomer)); + CountingStatusConverter.Calls = 0; + + try + { + FluentMapper.Initialize(configuration => configuration.AddMap(new ConversionCustomerMap())); + + using (var connection = OpenConnection()) + { + var customers = connection.QueryMapped( + @"SELECT 1 AS customer_id, 'A' AS status, '42' AS optional_score + UNION ALL + SELECT 2 AS customer_id, NULL AS status, NULL AS optional_score;") + .ToList(); + + Assert.Collection( + customers, + first => + { + Assert.Equal(1, first.Id); + Assert.Equal(AccountStatus.Active, first.Status); + Assert.Equal(42, first.OptionalScore); + }, + second => + { + Assert.Equal(2, second.Id); + Assert.Equal(AccountStatus.Unknown, second.Status); + Assert.Null(second.OptionalScore); + }); + Assert.Equal(1, CountingStatusConverter.Calls); + } + } + finally + { + PreTest(typeof(ConversionCustomer)); + } + } + + [Fact] + [Trait("Category", "Integration")] + public void QueryMappedShouldApplyNestedConverterOnlyToConfiguredMemberPath() + { + PreTest(typeof(NestedConversionCustomer)); + + try + { + FluentMapper.Initialize(configuration => configuration.AddMap(new NestedConversionCustomerMap())); + + using (var connection = OpenConnection()) + { + var customer = connection.QueryMappedSingle( + "SELECT '00123' AS billing_zip, '00456' AS shipping_zip;"); + + Assert.NotNull(customer.BillingAddress); + Assert.NotNull(customer.ShippingAddress); + Assert.Equal("ZIP-00123", customer.BillingAddress.ZipCode); + Assert.Equal("00456", customer.ShippingAddress.ZipCode); + } + } + finally + { + PreTest(typeof(NestedConversionCustomer)); + } + } + + [Fact] + [Trait("Category", "Integration")] + public void QueryMappedShouldNotInvokeNestedConverterWhenSubtreeIsNull() + { + PreTest(typeof(NestedConversionCustomer)); + CountingZipCodeConverter.Calls = 0; + + try + { + FluentMapper.Initialize(configuration => configuration.AddMap(new NestedConversionCustomerMap())); + + using (var connection = OpenConnection()) + { + var customer = connection.QueryMappedSingle( + "SELECT NULL AS billing_zip, NULL AS shipping_zip;"); + + Assert.Null(customer.BillingAddress); + Assert.Null(customer.ShippingAddress); + Assert.Equal(0, CountingZipCodeConverter.Calls); + } + } + finally + { + PreTest(typeof(NestedConversionCustomer)); + } + } + + [Fact] + [Trait("Category", "Integration")] + public void QueryMappedShouldApplyConverterBeforeImmutableConstructor() + { + PreTest(typeof(ImmutableConversionCustomer)); + + try + { + FluentMapper.Initialize(configuration => configuration.AddMap(new ImmutableConversionCustomerMap())); + + using (var connection = OpenConnection()) + { + var customer = connection.QueryMappedSingle( + "SELECT 'I' AS status;"); + + Assert.Equal(AccountStatus.Inactive, customer.Status); + } + } + finally + { + PreTest(typeof(ImmutableConversionCustomer)); + } + } + + [Fact] + [Trait("Category", "Integration")] + public void QueryMappedShouldApplyScalarValueObjectPropertyConverter() + { + PreTest(typeof(ValueObjectConversionCustomer)); + + try + { + FluentMapper.Initialize(configuration => configuration.AddMap(new ValueObjectConversionCustomerMap())); + + using (var connection = OpenConnection()) + { + var customer = connection.QueryMappedSingle( + "SELECT '12345678909' AS cpf;"); + + Assert.Equal("converted:12345678909", customer.Cpf.Number); + } + } + finally + { + PreTest(typeof(ValueObjectConversionCustomer)); + } + } + + [Fact] + [Trait("Category", "Integration")] + public void QueryMappedShouldRespectProfileScopedReadConverters() + { + PreTest(typeof(ProfileConversionCustomer)); + + try + { + FluentMapper.Initialize(configuration => + { + configuration.AddMap(new DefaultProfileConversionCustomerMap()); + configuration.AddProfile(); + }); + + using (var connection = OpenConnection()) + { + var current = connection.QueryMappedSingle( + "SELECT 'A' AS status;"); + var legacy = connection.QueryMappedSingle( + "SELECT '1' AS legacy_status;"); + + Assert.Equal(AccountStatus.Active, current.Status); + Assert.Equal(AccountStatus.Inactive, legacy.Status); + } + } + finally + { + PreTest(typeof(ProfileConversionCustomer)); + } + } + + [Fact] + public void ReadMappedShouldApplyReadConvertersFromCommonMaterializer() + { + PreTest(typeof(ConversionCustomer)); + + try + { + FluentMapper.Initialize(configuration => configuration.AddMap(new ConversionCustomerMap())); + + using (var reader = CreateReader(CreateTable( + new[] { "customer_id", "status", "optional_score" }, + new object[] { 9, "A", "17" }))) + using (var multi = new MappedGridReader(reader)) + { + var customer = multi.ReadMappedSingle(); + + Assert.Equal(9, customer.Id); + Assert.Equal(AccountStatus.Active, customer.Status); + Assert.Equal(17, customer.OptionalScore); + } + } + finally + { + PreTest(typeof(ConversionCustomer)); + } + } + + [Fact] + [Trait("Category", "Integration")] + public void QueryMappedUnbufferedShouldApplyReadConverters() + { + PreTest(typeof(ConversionCustomer)); + + try + { + FluentMapper.Initialize(configuration => configuration.AddMap(new ConversionCustomerMap())); + + using (var connection = new SqliteConnection("Data Source=:memory:")) + { + var customer = connection.QueryMappedUnbuffered( + "SELECT 10 AS customer_id, 'A' AS status, '22' AS optional_score;") + .Single(); + + Assert.Equal(10, customer.Id); + Assert.Equal(AccountStatus.Active, customer.Status); + Assert.Equal(22, customer.OptionalScore); + Assert.Equal(ConnectionState.Closed, connection.State); + } + } + finally + { + PreTest(typeof(ConversionCustomer)); + } + } + + [Fact] + [Trait("Category", "Integration")] + public async Task QueryMappedUnbufferedAsyncShouldApplyReadConverters() + { + PreTest(typeof(ConversionCustomer)); + + try + { + FluentMapper.Initialize(configuration => configuration.AddMap(new ConversionCustomerMap())); + + using (var connection = new SqliteConnection("Data Source=:memory:")) + { + var customer = (await ToListAsync(connection.QueryMappedUnbufferedAsync( + "SELECT 11 AS customer_id, 'A' AS status, '23' AS optional_score;", + TestContext.Current.CancellationToken))).Single(); + + Assert.Equal(11, customer.Id); + Assert.Equal(AccountStatus.Active, customer.Status); + Assert.Equal(23, customer.OptionalScore); + Assert.Equal(ConnectionState.Closed, connection.State); + } + } + finally + { + PreTest(typeof(ConversionCustomer)); + } + } + + [Fact] + [Trait("Category", "Integration")] + public void QueryMappedShouldUsePropertyConverterInsteadOfDapperTypeHandlerForThatProperty() + { + PreTest(typeof(TypeHandlerCoexistenceCustomer)); + + try + { + SqlMapper.AddTypeHandler(new HandledCodeTypeHandler()); + FluentMapper.Initialize(configuration => configuration.AddMap(new TypeHandlerCoexistenceCustomerMap())); + + using (var connection = OpenConnection()) + { + var customer = connection.QueryMappedSingle( + "SELECT 'one' AS property_code, 'two' AS handler_code;"); + + Assert.Equal("property:one", customer.PropertyCode.Value); + Assert.Equal("handler:two", customer.HandlerCode.Value); + } + } + finally + { + SqlMapper.ResetTypeHandlers(); + PreTest(typeof(TypeHandlerCoexistenceCustomer)); + } + } + + [Fact] + [Trait("Category", "Integration")] + public void QueryMappedShouldWrapConverterExceptionsWithMappingContext() + { + PreTest(typeof(ThrowingConversionCustomer)); + + try + { + FluentMapper.Initialize(configuration => configuration.AddMap(new ThrowingConversionCustomerMap())); + + using (var connection = OpenConnection()) + { + var exception = Assert.Throws( + () => connection.QueryMappedSingle( + "SELECT 'bad' AS status;")); + + Assert.IsType(exception.InnerException); + Assert.Contains(typeof(ThrowingConversionCustomer).FullName, exception.Message); + Assert.Contains(nameof(ThrowingConversionCustomer.Status), exception.Message); + Assert.Contains("status", exception.Message); + Assert.Contains(typeof(ThrowingStatusConverter).FullName, exception.Message); + Assert.Contains(typeof(string).FullName, exception.Message); + Assert.Contains(typeof(AccountStatus).FullName, exception.Message); + } + } + finally + { + PreTest(typeof(ThrowingConversionCustomer)); + } + } + + private static DataTableReader CreateReader(params DataTable[] tables) + { + return new DataTableReader(tables); + } + + private static DataTable CreateTable(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; + } + + private static SqliteConnection OpenConnection() + { + var connection = new SqliteConnection("Data Source=:memory:"); + connection.Open(); + return connection; + } + + private static async Task> ToListAsync(IAsyncEnumerable source) + { + var results = new List(); + + await foreach (var item in source) + { + results.Add(item); + } + + return results; + } + + private static void PreTest(params Type[] types) + { + FluentMapper.Reset(types); + } + + private enum AccountStatus + { + Unknown, + Active, + Inactive + } + + private sealed class ConversionCustomer + { + public int Id { get; set; } + + public AccountStatus Status { get; set; } + + public int? OptionalScore { get; set; } + } + + private sealed class ConversionCustomerMap : EntityMap + { + public ConversionCustomerMap() + { + Map(customer => customer.Id).ToColumn("customer_id"); + Map(customer => customer.Status).ToColumn("status").ConvertFromDatabaseUsing(); + Map(customer => customer.OptionalScore).ToColumn("optional_score").ConvertFromDatabaseUsing(); + } + } + + private sealed class CountingStatusConverter : IReadPropertyConverter + { + public static int Calls { get; set; } + + public AccountStatus ConvertFromDatabase(string value) + { + Calls++; + return value == "A" ? AccountStatus.Active : AccountStatus.Unknown; + } + } + + private sealed class ScoreConverter : IReadPropertyConverter + { + public int ConvertFromDatabase(string value) + { + return int.Parse(value); + } + } + + private sealed class NestedConversionCustomer + { + public Address BillingAddress { get; set; } + + public Address ShippingAddress { get; set; } + } + + private sealed class Address + { + public string ZipCode { get; set; } + } + + private sealed class NestedConversionCustomerMap : EntityMap + { + public NestedConversionCustomerMap() + { + Map(customer => customer.BillingAddress.ZipCode) + .ToColumn("billing_zip") + .ConvertFromDatabaseUsing(); + Map(customer => customer.ShippingAddress.ZipCode).ToColumn("shipping_zip"); + } + } + + private sealed class CountingZipCodeConverter : IReadPropertyConverter + { + public static int Calls { get; set; } + + public string ConvertFromDatabase(string value) + { + Calls++; + return "ZIP-" + value; + } + } + + private sealed class ImmutableConversionCustomer + { + public ImmutableConversionCustomer(AccountStatus status) + { + Status = status; + } + + public AccountStatus Status { get; } + } + + private sealed class ImmutableConversionCustomerMap : EntityMap + { + public ImmutableConversionCustomerMap() + { + Map(customer => customer.Status).ToColumn("status").ConvertFromDatabaseUsing(); + } + } + + private sealed class LegacyStatusConverter : IReadPropertyConverter + { + public AccountStatus ConvertFromDatabase(string value) + { + return value == "1" || value == "I" ? AccountStatus.Inactive : AccountStatus.Active; + } + } + + private sealed class ValueObjectConversionCustomer + { + public Cpf Cpf { get; set; } + } + + private sealed class ValueObjectConversionCustomerMap : EntityMap + { + public ValueObjectConversionCustomerMap() + { + Map(customer => customer.Cpf).ToColumn("cpf").ConvertFromDatabaseUsing(); + } + } + + private sealed class Cpf + { + public Cpf(string number) + { + Number = number; + } + + public string Number { get; } + } + + private sealed class CpfConverter : IReadPropertyConverter + { + public Cpf ConvertFromDatabase(string value) + { + return new Cpf("converted:" + value); + } + } + + private sealed class LegacyProfile : IMappingProfile + { + } + + private sealed class ProfileConversionCustomer + { + public AccountStatus Status { get; set; } + } + + private sealed class DefaultProfileConversionCustomerMap : EntityMap + { + public DefaultProfileConversionCustomerMap() + { + Map(customer => customer.Status).ToColumn("status").ConvertFromDatabaseUsing(); + } + } + + private sealed class LegacyProfileConversionCustomerMap : + EntityMap, + IProfileMap + { + public LegacyProfileConversionCustomerMap() + { + Map(customer => customer.Status).ToColumn("legacy_status").ConvertFromDatabaseUsing(); + } + } + + private sealed class HandledCode + { + public HandledCode(string value) + { + Value = value; + } + + public string Value { get; } + } + + private sealed class TypeHandlerCoexistenceCustomer + { + public HandledCode PropertyCode { get; set; } + + public HandledCode HandlerCode { get; set; } + } + + private sealed class TypeHandlerCoexistenceCustomerMap : EntityMap + { + public TypeHandlerCoexistenceCustomerMap() + { + Map(customer => customer.PropertyCode) + .ToColumn("property_code") + .ConvertFromDatabaseUsing(); + Map(customer => customer.HandlerCode).ToColumn("handler_code"); + } + } + + private sealed class PropertyCodeConverter : IReadPropertyConverter + { + public HandledCode ConvertFromDatabase(string value) + { + return new HandledCode("property:" + value); + } + } + + private sealed class HandledCodeTypeHandler : SqlMapper.TypeHandler + { + public override HandledCode Parse(object value) + { + return new HandledCode("handler:" + (string)value); + } + + public override void SetValue(IDbDataParameter parameter, HandledCode value) + { + parameter.Value = value == null ? DBNull.Value : value.Value; + } + } + + private sealed class ThrowingConversionCustomer + { + public AccountStatus Status { get; set; } + } + + private sealed class ThrowingConversionCustomerMap : EntityMap + { + public ThrowingConversionCustomerMap() + { + Map(customer => customer.Status).ToColumn("status").ConvertFromDatabaseUsing(); + } + } + + private sealed class ThrowingStatusConverter : IReadPropertyConverter + { + public AccountStatus ConvertFromDatabase(string value) + { + throw new InvalidOperationException("Invalid status."); + } + } + } +} From 074d74f09071849df0a47cbca712c640b5664ca4 Mon Sep 17 00:00:00 2001 From: Rodrigo de Oliveira Date: Tue, 28 Jul 2026 17:37:14 -0300 Subject: [PATCH 25/49] feat(generator): generate property conversion paths --- .sdd/etapa-10/05-performance-baseline.md | 49 ++ .sdd/etapa-10/06-generated-conversion.md | 214 ++++++++ .sdd/etapa-10/DECISIONS.md | 37 ++ .sdd/etapa-10/STATUS.md | 73 ++- README.md | 12 +- .../Dapper.FluentMap.Benchmarks/Program.cs | 18 + .../AnalyzerReleases.Unshipped.md | 1 + .../MappingRegistrationGenerator.cs | 462 ++++++++++++++++-- src/Dapper.FluentMap/MappingRegistry.cs | 26 +- .../GeneratedMaterializerColumn.cs | 82 +++- test/Dapper.FluentMap.AotSmoke/Program.cs | 36 ++ .../GeneratedRegistrationIntegrationTests.cs | 301 +++++++++++- .../MappingRegistrationGeneratorTests.cs | 125 +++++ .../PropertyConversionMetadataTests.cs | 38 ++ 14 files changed, 1429 insertions(+), 45 deletions(-) create mode 100644 .sdd/etapa-10/06-generated-conversion.md diff --git a/.sdd/etapa-10/05-performance-baseline.md b/.sdd/etapa-10/05-performance-baseline.md index 4311a44..14f1945 100644 --- a/.sdd/etapa-10/05-performance-baseline.md +++ b/.sdd/etapa-10/05-performance-baseline.md @@ -67,3 +67,52 @@ O resultado confirma que o caminho novo compila e executa sem regressao obvia de alocacao por linha. A diferenca favoravel dos converters nesta execucao parece mais ligada ao shape simples e ao custo da conversao padrao sem converter do que a uma otimizacao deliberada. Nao foi feita otimizacao prematura antes da medida. + +## Apos Prompt 10.4 + +O Prompt 10.4 adicionou materializacao gerada para read converters por tipo +estaticamente suportados. Foram executados benchmarks curtos em 2026-07-28, +mantendo `--job Dry`, `--warmupCount 1`, `--minIterationCount 1` e +`--maxIterationCount 2`. + +Comando para converter/no-converter em shapes de duas colunas: + +```powershell +dotnet run --configuration Release --project .\benchmarks\Dapper.FluentMap.Benchmarks\Dapper.FluentMap.Benchmarks.csproj -- --filter "*MaterializationSteadyStateBenchmarks.QueryMapped*Converter*" --job Dry --warmupCount 1 --minIterationCount 1 --maxIterationCount 2 +``` + +Resultado observado: + +| Method | Mean | Allocated | +|---|---:|---:| +| QueryMappedGeneratedSimpleConverter | 1.421 ms | 189.99 KB | +| QueryMappedRuntimePropertyConverter | 1.453 ms | 165.98 KB | +| QueryMappedRuntimeNoConverter | 1.579 ms | 142.55 KB | +| QueryMappedRuntimeSimpleConverter | 1.885 ms | 189.43 KB | +| QueryMappedGeneratedPropertyConverter | 2.036 ms | 166.55 KB | + +Comando para comparar o par gerado/runtime sem converter no shape simples +historico: + +```powershell +dotnet run --configuration Release --project .\benchmarks\Dapper.FluentMap.Benchmarks\Dapper.FluentMap.Benchmarks.csproj -- --filter "*MaterializationSteadyStateBenchmarks.QueryMappedSimple*" --job Dry --warmupCount 1 --minIterationCount 1 --maxIterationCount 2 +``` + +Recorte relevante: + +| Method | Mean | Allocated | +|---|---:|---:| +| QueryMappedSimpleRuntimeFallback | 3.100 ms | 361.63 KB | +| QueryMappedSimple | 3.270 ms | 362.82 KB | + +Interpretacao: + +- A execucao e smoke de performance, nao conclusao estatistica; o + BenchmarkDotNet alertou que os tempos de iteracao ficaram abaixo de 100 ms. +- O generated converter remove fallback runtime quando o shape casa, compila e + executa sem regressao obvia de alocacao. +- O resultado local ficou ruidoso: o converter simples gerado apareceu mais + rapido que o runtime equivalente, enquanto o Value Object/property converter + gerado apareceu mais lento nesta unica iteracao. +- A evidencia funcional mais importante do prompt continua sendo equivalencia + `runtime == generated` e cache runtime zerado no caminho gerado. diff --git a/.sdd/etapa-10/06-generated-conversion.md b/.sdd/etapa-10/06-generated-conversion.md new file mode 100644 index 0000000..43506c6 --- /dev/null +++ b/.sdd/etapa-10/06-generated-conversion.md @@ -0,0 +1,214 @@ +# Etapa 10 - Generated Property Conversion + +## Regra central + +Generated materialization e runtime materialization devem preservar: + +```text +runtime result == generated result +``` + +para toda conversao de leitura suportada pelos dois caminhos. O generated +materializer nao define uma semantica paralela; ele emite uma forma estatica da +mesma ordem efetiva do runtime: + +```text +null/DBNull handling + -> property read converter + -> default generated conversion +``` + +Quando nao ha property converter, o comportamento generated anterior permanece: +cast direto, enum, `Guid` de string e `Convert.ChangeType(..., +InvariantCulture)`. `TypeHandler` no generated path segue fora desta etapa e +continua dependendo do runtime fallback nos cenarios em que for necessario. + +## Converter discovery + +O source generator reconhece read converters somente quando a fluent chain usa +um converter por tipo estaticamente referenciavel: + +```csharp +Map(x => x.Status) + .ToColumn("status") + .ConvertFromDatabaseUsing(); +``` + +Tambem e reconhecido: + +```csharp +Map(x => x.Status) + .ConvertUsing(); +``` + +porque `ConvertUsing` inclui a direcao de leitura. + +O generator valida o contrato `IReadPropertyConverter` no +tipo do converter, com `TDatabase` compativel com o tipo declarado na chamada e +`TProperty` atribuivel ao tipo terminal do member path. `Nullable` e `T` +continuam equivalentes para matching de contrato, preservando a regra da etapa +10.2/10.3. + +## Converter construction + +Converters gerados sao materializados em campos estaticos privados da classe +gerada, um por binding de coluna/converter: + +```csharp +private static readonly StatusConverter Read0Converter1 = + new StatusConverter(); +``` + +Isso evita: + +- `Activator.CreateInstance` no hot path; +- reflection por linha; +- `dynamic`; +- criacao por linha. + +O contrato de lifetime continua sendo converter stateless/thread-safe. A +instancia gerada e separada da instancia criada pelo map runtime, mas preserva o +mesmo modelo operacional para converters por tipo: reuso e nenhuma alocacao por +linha. Converters por instancia/delegate fornecidos pelo usuario nao sao +duplicados pelo generator e usam runtime fallback. + +## Generated invocation + +Quando suportado, o hot path gerado chama um helper generico fortemente tipado: + +```csharp +entity.Status = + ReadConverted( + record, + 0, + Read0Converter0, + ...contexto diagnostico...); +``` + +O helper: + +- checa `DBNull` antes de chamar o converter; +- converte o valor bruto para `TDatabase` com a mesma conversao primitiva do + helper `Read`; +- chama `IReadPropertyConverter.ConvertFromDatabase`; +- retorna o tipo alvo real da propriedade/parametro (`TTarget`); +- encapsula falhas em `FluentMapConfigurationException` com inner exception. + +## Nullable handling + +`DBNull` e `null` nao sao enviados ao converter. + +- propriedades/reference targets nullable recebem `null`; +- value types nao nullable recebem `default(TTarget)`; +- conversores que retornam `T` podem alimentar propriedades `Nullable`; +- se um converter retorna `null` para target nao nullable, o generated path + falha com `FluentMapConfigurationException`, preservando inner exception. + +Esta regra corrige a diferenca sutil entre `TProperty` do converter e `TTarget` +do destino gerado, por exemplo `IReadPropertyConverter` aplicado a +`int?`. + +## Profile + +Descriptors generated agora carregam metadata de read converter por coluna: + +```text +column name +member path +read converter type +database/provider CLR type +converter property CLR type +``` + +O registry so seleciona o materializer gerado quando essa metadata coincide com +o map efetivo do profile selecionado. Isso separa corretamente: + +```text +default mapping +profile A +profile B +``` + +e evita colisao quando dois profiles usam a mesma entidade/coluna com +converters diferentes. + +## Nested + +Converters continuam associados a folhas terminais do member path: + +```text +BillingAddress.ZipCode +ShippingAddress.ZipCode +``` + +O generated path usa a mesma regra de subarvore do runtime: se todos os ordinais +de uma subarvore sao `DBNull`, a subarvore nao e criada e nenhum converter da +subarvore e chamado. + +## Immutable constructor + +Folhas com read converter sao convertidas antes de montar argumentos de +construtor. A selecao do construtor continua baseada no tipo terminal do member +path/propriedade, nao no `TDatabase` do converter. + +Generated materializers tambem suportam property converter em Value Objects +escalares quando o converter produz o objeto inteiro: + +```csharp +Map(x => x.Cpf) + .ToColumn("cpf") + .ConvertFromDatabaseUsing(); +``` + +## Diagnostics + +O generator emite `DFM012` como erro quando consegue provar que um converter por +tipo possui contrato read invalido para o member path, por exemplo: + +- nao implementa `IReadPropertyConverter` para o + `TDatabase` declarado; +- retorna `TProperty` nao atribuivel ao tipo terminal; +- possui mais de um contrato read compativel e ambiguo. + +O diagnostic `DFM011` permanece informativo para fallback de materializer +gerado quando a chain nao e suportada estaticamente. + +## Fallback + +O generator cai para runtime fallback, preservando comportamento suportado, em +casos como: + +- read converter por instancia; +- read converter por delegate; +- converter por tipo nao acessivel ao codigo gerado; +- chain fluent nao analisavel estaticamente; +- `IncludeBase`, conventions dinamicas e demais limites ja existentes do + generated materializer. + +Write-only converters sao neutros para generated read materialization. + +## Trimming + +O caminho gerado para converter por tipo e mais amigavel a trimming que runtime +fallback porque referencia o converter de forma estatica no codigo gerado e nao +usa ativacao dinamica no hot path. Ainda assim, `QueryMapped*` permanece +anotado como trimming/dynamic-code sensitive porque pode cair para runtime +fallback. + +Converters por instancia/delegate continuam suportados pelo runtime, mas nao +sao transformados em codigo gerado nesta etapa. + +## Native AOT + +Generated property conversion evita `Expression.Compile`, reflection por linha +e ativacao dinamica no materializer gerado. O impacto AOT e portanto +incrementalmente positivo nos cenarios em que: + +- o map e registrado por `AddGeneratedMappings()`; +- o converter e por tipo, acessivel e parameterless; +- o shape da query casa com o descriptor gerado; +- nao ha outro motivo para fallback runtime. + +A biblioteca continua nao devendo ser descrita como totalmente Native AOT +compatible. O smoke de trimming/AOT deve ser interpretado junto com os avisos +esperados de `QueryMapped*` e Dapper documentados nas etapas anteriores. diff --git a/.sdd/etapa-10/DECISIONS.md b/.sdd/etapa-10/DECISIONS.md index 76633e9..2a26feb 100644 --- a/.sdd/etapa-10/DECISIONS.md +++ b/.sdd/etapa-10/DECISIONS.md @@ -341,3 +341,40 @@ Todas as APIs que usam `MappedRowMaterializer` compartilham a mesma semantica: streaming assincrono. Generated materializers continuam caindo para runtime fallback quando o mapping efetivo possui read converter. Escrita/Dommel e execucao generated de converters permanecem incrementos separados. + +## ADR-13 - Prompt 10.4 generated read conversion + +### Contexto + +O runtime materializer ja executava read converters por propriedade, mas o +generated materializer recusava qualquer mapping efetivo com read converter e +caia para runtime fallback. Isso preservava corretude, mas impedia o beneficio +generated em cenarios simples e AOT-friendly. + +### Decisao + +Generated materializers passam a emitir read conversion somente quando o +converter e por tipo, acessivel ao codigo gerado, possui construtor publico +parameterless e implementa um contrato `IReadPropertyConverter` compativel com o member path. + +O codigo gerado usa um campo estatico por binding de coluna/converter e chama +um helper generico fortemente tipado. O descriptor de coluna gerado declara +tipo do converter, tipo de banco/provider e tipo de propriedade retornado pelo +converter. O registry so seleciona o descriptor quando essa metadata coincide +com o mapping efetivo do default map ou profile selecionado. + +Converters por instancia/delegate e converters inacessiveis para o codigo +gerado continuam usando runtime fallback. + +### Consequencias + +O caminho gerado passa a cobrir scalar, nullable, nested, immutable constructor, +Value Object escalar e profiles com property read converter, sem reflection por +linha nem `Activator.CreateInstance` no hot path. A semantica de null usa o tipo +alvo real da propriedade/parametro, preservando equivalencia para casos como +`IReadPropertyConverter` aplicado a `int?`. + +O novo diagnostic `DFM012` reporta contrato read invalido quando isso pode ser +provado em compile-time. Fallback continua sendo uma limitacao de otimizacao, +nao breaking change para cenarios suportados pelo runtime. diff --git a/.sdd/etapa-10/STATUS.md b/.sdd/etapa-10/STATUS.md index a75fc99..8e4a399 100644 --- a/.sdd/etapa-10/STATUS.md +++ b/.sdd/etapa-10/STATUS.md @@ -70,19 +70,33 @@ tipo e abrindo espaco para conversao por propriedade, map e profile. - Adicionados benchmarks especificos para no converter, simple converter, TypeHandler e property converter. - Criado `.sdd/etapa-10/05-performance-baseline.md`. +- Criado `.sdd/etapa-10/06-generated-conversion.md`. +- Generated materializers passam a emitir property read converters por tipo + quando o converter e estaticamente suportado. +- `GeneratedMaterializerColumn` passou a declarar metadata opcional de read + converter: converter type, database/provider type e converter property type. +- O registry valida descriptors gerados com converter contra o mapping efetivo, + separando default map e profiles sem colisao. +- O source generator emite campos estaticos de converter e chamadas genericas + fortemente tipadas para `ReadConverted`. +- Preservada a semantica `null/DBNull` externa ao converter, incluindo o caso + converter `T` aplicado a target `Nullable`. +- Adicionado diagnostic `DFM012` para contrato read converter invalido + comprovavel em compile-time. +- Converters por instancia/delegate e converters inacessiveis ao codigo gerado + continuam usando runtime fallback. +- Smoke AOT generated atualizado para cobrir property read converter. ## Em andamento -Generated materializer read conversion e write/Dommel conversion permanecem -adiadas para incrementos seguintes. +Write/Dommel conversion permanece adiada para incremento seguinte. ## Proximos passos -1. Evoluir generated read conversion ou fallback seguro quando houver converter. -2. Investigar e implementar write conversion/Dommel somente apos definir hook +1. Investigar e implementar write conversion/Dommel somente apos definir hook de parametros por propriedade. -3. Evoluir diagnostics/analyzers para reconhecer `Convert...`. -4. Aumentar benchmark formal quando houver decisao de otimizacao. +2. Evoluir diagnostics/analyzers alem do generator para reconhecer `Convert...`. +3. Aumentar benchmark formal quando houver decisao de otimizacao. ## Decisoes relevantes @@ -104,6 +118,9 @@ adiadas para incrementos seguintes. diagnostics, mantendo execucao de conversores para incremento posterior. - Prompt 10.3 executa read converters no runtime materializer comum e mantem generated/write conversion fora do escopo. +- Prompt 10.4 executa read converters no generated materializer somente para + converters por tipo estaticamente suportados e mantem fallback runtime para + instancia/delegate/inacessivel. ## APIs implementadas no Prompt 10.2 @@ -158,11 +175,12 @@ public interface IPropertyConverter : - Converter por reflection precisa de anotacoes de trimming e estrategia AOT. - Caches atuais assumem configuracao efetivamente imutavel apos registro. - Converter metadata ja existe, mas generated materializers ainda nao executam - read converters. Isso e intencional no Prompt 10.3 para evitar divergencia - runtime/generated antes da implementacao generated. + read converters por instancia/delegate; esses cenarios continuam no runtime + fallback. - `Convert...Using()` valida contrato por reflection de interfaces em configuration time; overloads por instancia/delegate oferecem caminho mais favoravel a AOT. +- TypeHandler no generated path permanece fora do escopo. ## Validacao do Prompt 10.1 @@ -206,6 +224,39 @@ public interface IPropertyConverter : iteracao ficaram abaixo de 100 ms; usar como baseline curta, nao como conclusao estatistica final. +## Validacao do Prompt 10.4 + +- `dotnet test .\test\Dapper.FluentMap.Generators.Tests\Dapper.FluentMap.Generators.Tests.csproj --configuration Release --filter FullyQualifiedName~MappingRegistrationGeneratorTests`: + sucesso, 26 testes aprovados. +- `dotnet test .\test\Dapper.FluentMap.GeneratedRegistration.Tests\Dapper.FluentMap.GeneratedRegistration.Tests.csproj --configuration Release --filter FullyQualifiedName~GeneratedRegistrationIntegrationTests`: + sucesso, 4 testes aprovados. +- `dotnet test .\test\Dapper.FluentMap.Tests\Dapper.FluentMap.Tests.csproj --configuration Release --filter FullyQualifiedName~PropertyConversionMetadataTests`: + sucesso, 18 testes aprovados. +- `dotnet restore .\Dapper.FluentMap.sln`: sucesso. +- `dotnet build .\Dapper.FluentMap.sln --configuration Release --no-restore`: + sucesso, 0 warnings, 0 errors. +- `dotnet test .\Dapper.FluentMap.sln --configuration Release --no-build`: + sucesso, 391 testes aprovados no total. +- `dotnet pack .\src\Dapper.FluentMap\Dapper.FluentMap.csproj --configuration Release --no-build --output .\artifacts\packages`: + sucesso, pacote criado em `artifacts/packages/Dapper.FluentMap.2.0.0.nupkg`; + warning conhecido `NU5125` sobre `licenseUrl` depreciado. +- `dotnet run --configuration Release --project .\benchmarks\Dapper.FluentMap.Benchmarks\Dapper.FluentMap.Benchmarks.csproj -- --filter "*MaterializationSteadyStateBenchmarks.QueryMapped*Converter*" --job Dry --warmupCount 1 --minIterationCount 1 --maxIterationCount 2`: + sucesso. Resultado observado: generated simple converter 1.421 ms / 189.99 + KB, runtime property converter 1.453 ms / 165.98 KB, runtime no converter + 1.579 ms / 142.55 KB, runtime simple converter 1.885 ms / 189.43 KB, + generated property converter 2.036 ms / 166.55 KB. +- `dotnet run --configuration Release --project .\benchmarks\Dapper.FluentMap.Benchmarks\Dapper.FluentMap.Benchmarks.csproj -- --filter "*MaterializationSteadyStateBenchmarks.QueryMappedSimple*" --job Dry --warmupCount 1 --minIterationCount 1 --maxIterationCount 2`: + sucesso. Recorte sem converter: runtime fallback 3.100 ms / 361.63 KB, + generated 3.270 ms / 362.82 KB. BenchmarkDotNet alertou que os tempos de + iteracao ficaram abaixo de 100 ms; usar como baseline curta. +- `dotnet publish .\test\Dapper.FluentMap.AotSmoke\Dapper.FluentMap.AotSmoke.csproj --configuration Release -p:PublishTrimmed=true -p:DefineConstants=AOT_SMOKE_GENERATED --output .\.tmp\aot-smoke\generated-trimmed` seguido de execucao do binario: + sucesso, executavel retornou `generated:ok`; warnings esperados `IL2026` em + `QueryMapped*` e `IL2104` em `Dapper.FluentMap`/`Dapper`. +- `dotnet publish .\test\Dapper.FluentMap.AotSmoke\Dapper.FluentMap.AotSmoke.csproj --configuration Release -p:PublishAot=true -p:DefineConstants=AOT_SMOKE_GENERATED --output .\.tmp\aot-smoke\generated-aot`: + bloqueado pelo ambiente com `Platform linker not found`; antes do bloqueio + foram emitidos warnings esperados `IL2026` e `IL3050` nas chamadas + `QueryMapped*`. + ## Interacao com Dapper TypeHandler Precedencia proposta para `QueryMapped*`: @@ -239,12 +290,14 @@ connection.Query() - `.sdd/etapa-10/03-converter-contract-design.md` - `.sdd/etapa-10/04-runtime-conversion.md` - `.sdd/etapa-10/05-performance-baseline.md` +- `.sdd/etapa-10/06-generated-conversion.md` - `.sdd/etapa-10/DECISIONS.md` - `.sdd/etapa-10/STATUS.md` - `src/Dapper.FluentMap/Materialization/NestedMaterializationPlan.cs` - `src/Dapper.FluentMap/Materialization/MappedRowMaterializer.cs` - `src/Dapper.FluentMap/Compatibility/DapperTypeHandlerAdapter.cs` - `src/Dapper.FluentMap.Generators/MappingRegistrationGenerator.cs` +- `src/Dapper.FluentMap.Generators/AnalyzerReleases.Unshipped.md` - `src/Dapper.FluentMap.Analyzers/FluentMapConfigurationAnalyzer.cs` - `src/Dapper.FluentMap/Mapping/PropertyMap.cs` - `src/Dapper.FluentMap/Mapping/PropertyConversionMetadata.cs` @@ -257,8 +310,10 @@ connection.Query() - `test/Dapper.FluentMap.Tests/AdvancedQueryHardeningTests.cs` - `test/Dapper.FluentMap.Tests/PropertyConversionMetadataTests.cs` - `test/Dapper.FluentMap.Tests/RuntimeReadConversionTests.cs` +- `test/Dapper.FluentMap.GeneratedRegistration.Tests/GeneratedRegistrationIntegrationTests.cs` +- `test/Dapper.FluentMap.AotSmoke/Program.cs` - `benchmarks/Dapper.FluentMap.Benchmarks/Program.cs` ## Ultimo prompt executado -Ultimo prompt executado: 10.3 +Ultimo prompt executado: 10.4 diff --git a/README.md b/README.md index e63ae1e..3171b10 100644 --- a/README.md +++ b/README.md @@ -176,6 +176,10 @@ Dapper `TypeHandler` or FluentMap's default conversion. Normal Dapper queries (`Query()`) and Dommel write operations are unchanged; write converter metadata is stored for a later parameter-conversion increment. +Generated materializers can emit property read converter calls for statically +supported converter-type mappings. Converter instances and delegates continue to +use the runtime fallback. + Inherited explicit mappings can be included when the derived entity should reuse a base entity map: ```csharp @@ -418,7 +422,7 @@ FluentMapper.Initialize(config => }); ``` -Generated registration calls the existing `AddMap()` / `AddProfile()` paths. For explicit maps with literal columns and supported deterministic construction, it also registers generated row materializers for the matching ordered column shape, including flat properties, nested object paths and constructor-built Value Objects. Unsupported maps and unexpected shapes continue to use the runtime fallback. It does not scan referenced assemblies, execute map constructors during generation or replace `FluentMapper.Validate()`. +Generated registration calls the existing `AddMap()` / `AddProfile()` paths. For explicit maps with literal columns and supported deterministic construction, it also registers generated row materializers for the matching ordered column shape, including flat properties, nested object paths, constructor-built Value Objects and statically supported property read converters. Unsupported maps and unexpected shapes continue to use the runtime fallback. It does not scan referenced assemblies, execute map constructors during generation or replace `FluentMapper.Validate()`. The core runtime also exposes low-level generated materializer registration contracts for generator-emitted code. These contracts are additive infrastructure; current consumers do not need to register materializers manually, and missing generated materializers continue to use the existing runtime fallback. @@ -790,6 +794,10 @@ Consultas Dapper normais (`Query()`) e escritas Dommel nao mudam; metadata de write converter fica armazenada para um incremento futuro de conversao de parametros. +Materializadores gerados podem emitir chamadas de read converter por propriedade +quando o mapping usa um converter por tipo suportado estaticamente. Converters +por instancia e delegate continuam usando runtime fallback. + Mapeamentos explícitos herdados podem ser incluídos quando a entidade derivada deve reutilizar um map da entidade base: ```csharp @@ -1032,7 +1040,7 @@ FluentMapper.Initialize(config => }); ``` -O registro gerado chama os caminhos existentes `AddMap()` / `AddProfile()`. Para maps explícitos com colunas literais e construção determinística suportada, ele também registra materializadores de linha gerados para o shape ordenado de colunas correspondente, incluindo propriedades flat, caminhos aninhados e Value Objects construídos por construtor. Maps não suportados e shapes inesperados continuam usando o fallback runtime. Ele não escaneia assemblies referenciados, não executa construtores de maps durante a geração e não substitui `FluentMapper.Validate()`. +O registro gerado chama os caminhos existentes `AddMap()` / `AddProfile()`. Para maps explícitos com colunas literais e construção determinística suportada, ele também registra materializadores de linha gerados para o shape ordenado de colunas correspondente, incluindo propriedades flat, caminhos aninhados, Value Objects construídos por construtor e property read converters suportados estaticamente. Maps não suportados e shapes inesperados continuam usando o fallback runtime. Ele não escaneia assemblies referenciados, não executa construtores de maps durante a geração e não substitui `FluentMapper.Validate()`. O runtime principal também expõe contratos de baixo nível para registro de materializadores gerados por código emitido por generator. Esses contratos são infraestrutura aditiva; consumidores atuais não precisam registrar materializadores manualmente, e a ausência de materializadores gerados continua usando o fallback runtime existente. diff --git a/benchmarks/Dapper.FluentMap.Benchmarks/Program.cs b/benchmarks/Dapper.FluentMap.Benchmarks/Program.cs index e0386cf..435d9fc 100644 --- a/benchmarks/Dapper.FluentMap.Benchmarks/Program.cs +++ b/benchmarks/Dapper.FluentMap.Benchmarks/Program.cs @@ -63,8 +63,10 @@ public async Task GlobalSetup() QueryMultipleMappedSimple(); QueryMultipleMappedSimpleRuntimeFallback(); QueryMappedRuntimeNoConverter(); + QueryMappedGeneratedSimpleConverter(); QueryMappedRuntimeSimpleConverter(); QueryMappedRuntimeTypeHandler(); + QueryMappedGeneratedPropertyConverter(); QueryMappedRuntimePropertyConverter(); } @@ -236,6 +238,14 @@ public int QueryMappedRuntimeNoConverter() .Count(); } + [Benchmark] + public int QueryMappedGeneratedSimpleConverter() + { + return _connection.QueryMapped( + "SELECT Id AS customer_id, Name AS full_name FROM BenchmarkRows;") + .Count(); + } + [Benchmark] public int QueryMappedRuntimeSimpleConverter() { @@ -252,6 +262,14 @@ public int QueryMappedRuntimeTypeHandler() .Count(); } + [Benchmark] + public int QueryMappedGeneratedPropertyConverter() + { + return _connection.QueryMapped( + "SELECT Id AS customer_id, Cpf AS code FROM BenchmarkRows;") + .Count(); + } + [Benchmark] public int QueryMappedRuntimePropertyConverter() { diff --git a/src/Dapper.FluentMap.Generators/AnalyzerReleases.Unshipped.md b/src/Dapper.FluentMap.Generators/AnalyzerReleases.Unshipped.md index 15b095b..7011f7b 100644 --- a/src/Dapper.FluentMap.Generators/AnalyzerReleases.Unshipped.md +++ b/src/Dapper.FluentMap.Generators/AnalyzerReleases.Unshipped.md @@ -9,3 +9,4 @@ DFM006 | Dapper.FluentMap.Configuration | Info | Entity map type is skipped by g 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 DFM011 | Dapper.FluentMap.Configuration | Info | Entity map uses runtime materializer fallback for generated materialization +DFM012 | Dapper.FluentMap.Configuration | Error | Generated read converter is invalid diff --git a/src/Dapper.FluentMap.Generators/MappingRegistrationGenerator.cs b/src/Dapper.FluentMap.Generators/MappingRegistrationGenerator.cs index 9facc1e..754e07d 100644 --- a/src/Dapper.FluentMap.Generators/MappingRegistrationGenerator.cs +++ b/src/Dapper.FluentMap.Generators/MappingRegistrationGenerator.cs @@ -18,6 +18,7 @@ public sealed class MappingRegistrationGenerator : IIncrementalGenerator public const string DuplicateGeneratedEntityMapDiagnosticId = "DFM007"; public const string DuplicateGeneratedProfileMapDiagnosticId = "DFM008"; public const string SkippedGeneratedMaterializerDiagnosticId = "DFM011"; + public const string InvalidGeneratedReadConverterDiagnosticId = "DFM012"; private const string Category = "Dapper.FluentMap.Configuration"; private const string MappingNamespace = "Dapper.FluentMap.Mapping"; @@ -68,6 +69,15 @@ public sealed class MappingRegistrationGenerator : IIncrementalGenerator isEnabledByDefault: true, description: "Generated materializers are emitted only for statically known explicit mappings with supported object construction. Unsupported mappings continue to use the runtime fallback."); + private static readonly DiagnosticDescriptor InvalidGeneratedReadConverterRule = new DiagnosticDescriptor( + InvalidGeneratedReadConverterDiagnosticId, + "Generated read converter is invalid", + "Read converter '{1}' on entity map type '{0}' cannot be emitted by the generated materializer: {2}", + Category, + DiagnosticSeverity.Error, + isEnabledByDefault: true, + description: "Generated materializers require statically known read converters to implement a compatible IReadPropertyConverter contract."); + private static readonly SymbolDisplayFormat FullyQualifiedTypeFormat = new SymbolDisplayFormat( globalNamespaceStyle: SymbolDisplayGlobalNamespaceStyle.Included, typeQualificationStyle: SymbolDisplayTypeQualificationStyle.NameAndContainingTypesAndNamespaces, @@ -160,7 +170,8 @@ private static MapCandidate CreateMapCandidate( profileTypeName, context.SemanticModel, cancellationToken, - out var materializerSkipReason); + out var materializerSkipReason, + out var materializerDiagnostic); return MapCandidate.Valid( mapDisplayName, @@ -170,7 +181,8 @@ private static MapCandidate CreateMapCandidate( GetInheritanceDepth(entityType), location, materializer, - materializerSkipReason); + materializerSkipReason, + materializerDiagnostic); } private static void Execute( @@ -235,6 +247,14 @@ private static void ReportCandidateDiagnostic(SourceProductionContext context, M candidate.MapDisplayName, candidate.MaterializerSkipReason)); } + + if (candidate.MaterializerDiagnostic != null) + { + context.ReportDiagnostic(Diagnostic.Create( + candidate.MaterializerDiagnostic.Descriptor, + candidate.MaterializerDiagnostic.Location, + candidate.MaterializerDiagnostic.Arguments)); + } } private static ISet ReportDuplicateEntityMaps( @@ -367,6 +387,11 @@ private static string CreateGeneratedSource(IList maps) builder.AppendLine(" internal static class DapperFluentMapGeneratedMaterializers"); builder.AppendLine(" {"); + foreach (var materializer in materializers) + { + AppendMaterializerConverterFields(builder, materializer); + } + foreach (var materializer in materializers) { AppendMaterializerMethod(builder, materializer); @@ -405,6 +430,16 @@ private static void AppendGeneratedMaterializerRegistration(StringBuilder builde { builder.Append(", "); builder.Append(EscapeStringLiteral(column.MemberPath)); + if (column.ReadConverter != null) + { + builder.Append(", typeof("); + builder.Append(column.ReadConverter.ConverterTypeName); + builder.Append("), typeof("); + builder.Append(column.ReadConverter.DatabaseTypeName); + builder.Append("), typeof("); + builder.Append(column.ReadConverter.PropertyTypeName); + builder.Append(')'); + } } builder.Append(index == materializer.Columns.Count - 1 ? ")" : "),"); @@ -417,6 +452,20 @@ private static void AppendGeneratedMaterializerRegistration(StringBuilder builde builder.AppendLine(")"); } + private static void AppendMaterializerConverterFields(StringBuilder builder, GeneratedMaterializerInfo materializer) + { + foreach (var leaf in materializer.Root.GetLeaves().Where(leaf => leaf.ReadConverter != null)) + { + builder.Append(" private static readonly "); + builder.Append(leaf.ReadConverter.ConverterTypeName); + builder.Append(' '); + builder.Append(GetConverterFieldName(materializer, leaf)); + builder.Append(" = new "); + builder.Append(leaf.ReadConverter.ConverterTypeName); + builder.AppendLine("();"); + } + } + private static void AppendMaterializerMethod(StringBuilder builder, GeneratedMaterializerInfo materializer) { builder.AppendLine(); @@ -432,7 +481,7 @@ private static void AppendMaterializerMethod(StringBuilder builder, GeneratedMat builder.AppendLine(" }"); builder.AppendLine(); - AppendMaterializeNode(builder, materializer.Root, "entity", " ", null, materializer.EntityTypeName, localAlreadyDeclared: false); + AppendMaterializeNode(builder, materializer, materializer.Root, "entity", " ", null, materializer.EntityTypeName, localAlreadyDeclared: false); builder.AppendLine(); builder.AppendLine(" return entity;"); @@ -441,6 +490,7 @@ private static void AppendMaterializerMethod(StringBuilder builder, GeneratedMat private static void AppendMaterializeNode( StringBuilder builder, + GeneratedMaterializerInfo materializer, GeneratedMaterializationNode node, string localName, string indent, @@ -454,17 +504,17 @@ private static void AppendMaterializeNode( } else { - AppendCreateConstructorNode(builder, node, localName, indent, entityTypeName, localAlreadyDeclared); + AppendCreateConstructorNode(builder, materializer, node, localName, indent, entityTypeName, localAlreadyDeclared); } foreach (var child in node.PostConstructorChildren) { - AppendApplyChild(builder, child, localName, indent, entityTypeName); + AppendApplyChild(builder, materializer, child, localName, indent, entityTypeName); } foreach (var leaf in node.PostConstructorLeaves) { - AppendAssignLeaf(builder, leaf, localName, indent); + AppendAssignLeaf(builder, materializer, leaf, localName, indent); } } @@ -529,6 +579,7 @@ private static void AppendCreateParameterlessNode( private static void AppendCreateConstructorNode( StringBuilder builder, + GeneratedMaterializerInfo materializer, GeneratedMaterializationNode node, string localName, string indent, @@ -542,15 +593,13 @@ private static void AppendCreateConstructorNode( builder.Append(indent); builder.Append("var "); builder.Append(parameter.LocalName); - builder.Append(" = Read<"); - builder.Append(parameter.TypeName); - builder.Append(">(record, "); - builder.Append(parameter.Leaf.Ordinal.ToString(System.Globalization.CultureInfo.InvariantCulture)); - builder.AppendLine(");"); + builder.Append(" = "); + AppendReadExpression(builder, materializer, parameter.Leaf); + builder.AppendLine(";"); continue; } - AppendCreateChildValue(builder, parameter.Child, parameter.LocalName, indent, entityTypeName); + AppendCreateChildValue(builder, materializer, parameter.Child, parameter.LocalName, indent, entityTypeName); } builder.AppendLine(); @@ -592,6 +641,7 @@ private static void AppendCreateConstructorNode( private static void AppendApplyChild( StringBuilder builder, + GeneratedMaterializerInfo materializer, GeneratedMaterializationNode child, string parentLocalName, string indent, @@ -604,7 +654,7 @@ private static void AppendApplyChild( builder.Append(indent); builder.AppendLine("{"); var childLocalName = "node" + child.Id.ToString(System.Globalization.CultureInfo.InvariantCulture); - AppendMaterializeNode(builder, child, childLocalName, indent + " ", parentLocalName, entityTypeName, localAlreadyDeclared: false); + AppendMaterializeNode(builder, materializer, child, childLocalName, indent + " ", parentLocalName, entityTypeName, localAlreadyDeclared: false); if (child.HasPublicSetter && (child.Constructor != null || !child.HasPublicGetter)) { builder.Append(indent); @@ -638,6 +688,7 @@ private static void AppendApplyChild( private static void AppendCreateChildValue( StringBuilder builder, + GeneratedMaterializerInfo materializer, GeneratedMaterializationNode child, string localName, string indent, @@ -654,13 +705,14 @@ private static void AppendCreateChildValue( builder.AppendLine(")"); builder.Append(indent); builder.AppendLine("{"); - AppendMaterializeNode(builder, child, localName, indent + " ", null, entityTypeName, localAlreadyDeclared: true); + AppendMaterializeNode(builder, materializer, child, localName, indent + " ", null, entityTypeName, localAlreadyDeclared: true); builder.Append(indent); builder.AppendLine("}"); } private static void AppendAssignLeaf( StringBuilder builder, + GeneratedMaterializerInfo materializer, GeneratedPropertyBinding leaf, string targetLocalName, string indent) @@ -669,11 +721,60 @@ private static void AppendAssignLeaf( builder.Append(targetLocalName); builder.Append('.'); builder.Append(EscapeIdentifier(leaf.PropertyName)); - builder.Append(" = Read<"); + builder.Append(" = "); + AppendReadExpression(builder, materializer, leaf); + builder.AppendLine(";"); + } + + private static void AppendReadExpression( + StringBuilder builder, + GeneratedMaterializerInfo materializer, + GeneratedPropertyBinding leaf) + { + if (leaf.ReadConverter == null) + { + builder.Append("Read<"); + builder.Append(leaf.TypeName); + builder.Append(">(record, "); + builder.Append(leaf.Ordinal.ToString(System.Globalization.CultureInfo.InvariantCulture)); + builder.Append(')'); + return; + } + + builder.Append("ReadConverted<"); + builder.Append(leaf.ReadConverter.DatabaseTypeName); + builder.Append(", "); + builder.Append(leaf.ReadConverter.PropertyTypeName); + builder.Append(", "); builder.Append(leaf.TypeName); builder.Append(">(record, "); builder.Append(leaf.Ordinal.ToString(System.Globalization.CultureInfo.InvariantCulture)); - builder.AppendLine(");"); + builder.Append(", "); + builder.Append(GetConverterFieldName(materializer, leaf)); + builder.Append(", "); + builder.Append(EscapeStringLiteral(materializer.EntityTypeName)); + builder.Append(", "); + builder.Append(materializer.ProfileTypeName == null + ? "null" + : EscapeStringLiteral(materializer.ProfileTypeName)); + builder.Append(", "); + builder.Append(EscapeStringLiteral(leaf.MemberPath)); + builder.Append(", "); + builder.Append(EscapeStringLiteral(leaf.ColumnName)); + builder.Append(", "); + builder.Append(EscapeStringLiteral(leaf.ReadConverter.ConverterTypeName)); + builder.Append(", "); + builder.Append(EscapeStringLiteral(leaf.ReadConverter.DatabaseTypeName)); + builder.Append(", "); + builder.Append(EscapeStringLiteral(leaf.ReadConverter.PropertyTypeName)); + builder.Append(", "); + builder.Append(EscapeStringLiteral(leaf.TypeName)); + builder.Append(')'); + } + + private static string GetConverterFieldName(GeneratedMaterializerInfo materializer, GeneratedPropertyBinding leaf) + { + return materializer.MethodName + "Converter" + leaf.Ordinal.ToString(System.Globalization.CultureInfo.InvariantCulture); } private static void AppendHasAnyValueExpression(StringBuilder builder, GeneratedMaterializationNode node) @@ -714,6 +815,56 @@ private static void AppendReadHelper(StringBuilder builder) builder.AppendLine(" }"); builder.AppendLine(); builder.AppendLine(" var value = record.GetValue(ordinal);"); + builder.AppendLine(" return ConvertValue(value);"); + builder.AppendLine(" }"); + builder.AppendLine(); + builder.AppendLine(" private static TTarget ReadConverted("); + builder.AppendLine(" global::System.Data.IDataRecord record,"); + builder.AppendLine(" int ordinal,"); + builder.AppendLine(" global::Dapper.FluentMap.Mapping.IReadPropertyConverter converter,"); + builder.AppendLine(" string entityTypeName,"); + builder.AppendLine(" string profileTypeName,"); + builder.AppendLine(" string memberPath,"); + builder.AppendLine(" string columnName,"); + builder.AppendLine(" string converterTypeName,"); + builder.AppendLine(" string converterDatabaseTypeName,"); + builder.AppendLine(" string converterPropertyTypeName,"); + builder.AppendLine(" string targetTypeName)"); + builder.AppendLine(" {"); + builder.AppendLine(" var value = record.GetValue(ordinal);"); + builder.AppendLine(" if (value == null || value == global::System.DBNull.Value)"); + builder.AppendLine(" {"); + builder.AppendLine(" return default(TTarget);"); + builder.AppendLine(" }"); + builder.AppendLine(); + builder.AppendLine(" try"); + builder.AppendLine(" {"); + builder.AppendLine(" var converted = converter.ConvertFromDatabase(ConvertValue(value));"); + builder.AppendLine(" if ((object)converted == null && (object)default(TTarget) != null)"); + builder.AppendLine(" {"); + builder.AppendLine(" throw new global::System.InvalidOperationException("); + builder.AppendLine(" \"Read converter '\" + converterTypeName + \"' returned null for non-nullable target type '\" + targetTypeName + \"'.\");"); + builder.AppendLine(" }"); + builder.AppendLine(); + builder.AppendLine(" return (TTarget)(object)converted;"); + builder.AppendLine(" }"); + builder.AppendLine(" catch (global::System.Exception exception) when (!(exception is global::Dapper.FluentMap.FluentMapConfigurationException))"); + builder.AppendLine(" {"); + builder.AppendLine(" var profileContext = profileTypeName == null"); + builder.AppendLine(" ? string.Empty"); + builder.AppendLine(" : \" Profile: '\" + profileTypeName + \"'.\";"); + builder.AppendLine(" throw new global::Dapper.FluentMap.FluentMapConfigurationException("); + builder.AppendLine(" \"Read converter failed for entity '\" + entityTypeName + \"'.\" + profileContext +"); + builder.AppendLine(" \" Member path: '\" + memberPath + \"'. Column: '\" + columnName + \"'. Converter: '\" + converterTypeName +"); + builder.AppendLine(" \"'. Source type: '\" + value.GetType().FullName + \"'. Converter database type: '\" + converterDatabaseTypeName +"); + builder.AppendLine(" \"'. Converter property type: '\" + converterPropertyTypeName + \"'. Target type: '\" + targetTypeName +"); + builder.AppendLine(" \"'. See the inner exception for the converter failure.\","); + builder.AppendLine(" exception);"); + builder.AppendLine(" }"); + builder.AppendLine(" }"); + builder.AppendLine(); + builder.AppendLine(" private static T ConvertValue(object value)"); + builder.AppendLine(" {"); builder.AppendLine(" if (value is T typedValue)"); builder.AppendLine(" {"); builder.AppendLine(" return typedValue;"); @@ -745,9 +896,11 @@ private static GeneratedMaterializerInfo TryCreateGeneratedMaterializer( string profileTypeName, SemanticModel semanticModel, System.Threading.CancellationToken cancellationToken, - out string skipReason) + out string skipReason, + out GeneratedDiagnostic diagnostic) { skipReason = null; + diagnostic = null; var constructor = GetPublicParameterlessConstructorDeclaration(classDeclaration, mapType, semanticModel, cancellationToken); if (constructor == null || constructor.Body == null) @@ -770,7 +923,14 @@ private static GeneratedMaterializerInfo TryCreateGeneratedMaterializer( continue; } - if (!TryCreateDirectMapInvocation(invocation, semanticModel, cancellationToken, out var mapInvocation, out skipReason)) + if (!TryCreateDirectMapInvocation( + invocation, + mapType, + semanticModel, + cancellationToken, + out var mapInvocation, + out skipReason, + out diagnostic)) { return null; } @@ -798,7 +958,8 @@ private static GeneratedMaterializerInfo TryCreateGeneratedMaterializer( columns.Add(new GeneratedColumnBinding( invocation.ColumnName, invocation.MemberPath.Display, - invocation.Ignored)); + invocation.Ignored, + invocation.ReadConverter)); if (invocation.Ignored) { @@ -846,12 +1007,18 @@ private static bool TryAddMaterializedPath( } var leaf = properties[properties.Count - 1]; - if (!IsSupportedScalarType(leaf.Type)) + if (invocation.ReadConverter == null && !IsSupportedScalarType(leaf.Type)) { skipReason = $"property '{invocation.MemberPath.Display}' has type '{FormatSymbol(leaf.Type)}', which is not supported by generated materializers"; return false; } + if (invocation.ReadConverter != null && !IsSupportedConvertedPropertyType(leaf.Type)) + { + skipReason = $"property '{invocation.MemberPath.Display}' has converted type '{FormatSymbol(leaf.Type)}', which is not accessible from generated materializers"; + return false; + } + var node = root; for (var index = 0; index < properties.Count - 1; index++) { @@ -865,7 +1032,8 @@ private static bool TryAddMaterializedPath( leaf.Name, leaf.Type.ToDisplayString(FullyQualifiedTypeFormat), HasPublicSetter(leaf), - leaf.Type)); + leaf.Type, + invocation.ReadConverter)); return true; } @@ -910,13 +1078,16 @@ private static bool ContainsIncludeBaseInvocation( private static bool TryCreateDirectMapInvocation( InvocationExpressionSyntax mapInvocation, + INamedTypeSymbol mapType, SemanticModel semanticModel, System.Threading.CancellationToken cancellationToken, out GeneratedMapInvocation result, - out string skipReason) + out string skipReason, + out GeneratedDiagnostic diagnostic) { result = null; skipReason = null; + diagnostic = null; if (mapInvocation.ArgumentList.Arguments.Count != 1 || !TryGetLambda(mapInvocation.ArgumentList.Arguments[0].Expression, out var lambda)) @@ -939,6 +1110,7 @@ private static bool TryCreateDirectMapInvocation( var column = memberPath.TerminalName; var ignored = false; + var readConverter = default(GeneratedReadConverterBinding); SyntaxNode current = mapInvocation; while (current.Parent is MemberAccessExpressionSyntax memberAccess && memberAccess.Expression == current && @@ -957,6 +1129,46 @@ private static bool TryCreateDirectMapInvocation( { ignored = true; } + else if (IsGeneratedReadConverterInvocation(chainedMethod)) + { + if (chainedInvocation.ArgumentList.Arguments.Count != 0) + { + skipReason = "read converter instances and delegates are not statically supported by generated materializers"; + return false; + } + + if (readConverter != null) + { + skipReason = "multiple read converters in the same map chain are not supported by generated materializers"; + return false; + } + + if (!TryCreateReadConverterBinding( + chainedMethod, + memberPath.Properties[memberPath.Properties.Count - 1].Type, + out readConverter, + out var converterReason)) + { + if (IsGeneratedReadConverterFallbackReason(converterReason)) + { + skipReason = converterReason; + return false; + } + + diagnostic = GeneratedDiagnostic.InvalidReadConverter( + chainedInvocation.GetLocation(), + mapType.ToDisplayString(FullyQualifiedTypeFormat), + chainedMethod.TypeArguments.Length > 0 + ? chainedMethod.TypeArguments[0].ToDisplayString(FullyQualifiedTypeFormat) + : chainedMethod.Name, + converterReason); + return false; + } + } + else if (IsWriteOnlyConverterInvocation(chainedMethod)) + { + // Write-only conversion metadata does not change the generated read materializer. + } else if (IsReadNeutralPersistenceInvocation(chainedMethod)) { // Write-only metadata does not change the generated read materializer. @@ -976,10 +1188,16 @@ private static bool TryCreateDirectMapInvocation( return false; } - result = new GeneratedMapInvocation(memberPath, column, ignored); + result = new GeneratedMapInvocation(memberPath, column, ignored, readConverter); return true; } + private static bool IsGeneratedReadConverterFallbackReason(string reason) + { + return string.Equals(reason, "the converter type is not accessible from generated code", StringComparison.Ordinal) || + string.Equals(reason, "the converter type does not have a public parameterless constructor", StringComparison.Ordinal); + } + private static bool TryGetColumn( InvocationExpressionSyntax invocation, SemanticModel semanticModel, @@ -1283,6 +1501,18 @@ private static bool IsSupportedComplexType(ITypeSymbol type) IsAccessibleFromGeneratedCode(namedType); } + private static bool IsSupportedConvertedPropertyType(ITypeSymbol type) + { + var unwrapped = UnwrapNullable(type); + if (IsSupportedScalarType(unwrapped)) + { + return true; + } + + var namedType = unwrapped as INamedTypeSymbol; + return namedType != null && IsAccessibleFromGeneratedCode(namedType); + } + private static bool CanAssignNull(ITypeSymbol type) { var namedType = type as INamedTypeSymbol; @@ -1357,6 +1587,102 @@ private static bool IsReadNeutralPersistenceInvocation(IMethodSymbol method) } } + private static bool IsGeneratedReadConverterInvocation(IMethodSymbol method) + { + return method != null && + (method.Name == "ConvertFromDatabaseUsing" || method.Name == "ConvertUsing") && + method.IsGenericMethod && + method.TypeArguments.Length == 2; + } + + private static bool IsWriteOnlyConverterInvocation(IMethodSymbol method) + { + return method != null && method.Name == "ConvertToDatabaseUsing"; + } + + private static bool TryCreateReadConverterBinding( + IMethodSymbol method, + ITypeSymbol mappedPropertyType, + out GeneratedReadConverterBinding binding, + out string reason) + { + binding = null; + reason = null; + + var converterType = method.TypeArguments[0] as INamedTypeSymbol; + var databaseType = method.TypeArguments[1]; + if (converterType == null) + { + reason = "the converter type is not a named type"; + return false; + } + + if (!HasPublicParameterlessConstructor(converterType)) + { + reason = "the converter type does not have a public parameterless constructor"; + return false; + } + + if (!IsAccessibleFromGeneratedCode(converterType)) + { + reason = "the converter type is not accessible from generated code"; + return false; + } + + var databaseMatches = converterType.AllInterfaces + .Where(type => IsReadPropertyConverterInterface(type)) + .Where(type => IsSameOrNullableEquivalent(type.TypeArguments[0], databaseType)) + .ToList(); + + if (databaseMatches.Count == 0) + { + reason = $"the converter does not implement IReadPropertyConverter<{FormatSymbol(databaseType)}, TProperty>"; + return false; + } + + var propertyMatches = databaseMatches + .Where(type => CanAssignValue(mappedPropertyType, type.TypeArguments[1])) + .ToList(); + + if (propertyMatches.Count == 0) + { + var converterPropertyType = databaseMatches[0].TypeArguments[1]; + reason = $"the converter returns '{FormatSymbol(converterPropertyType)}', which cannot be assigned to mapped property type '{FormatSymbol(mappedPropertyType)}'"; + return false; + } + + if (propertyMatches.Count > 1) + { + reason = "the converter matches more than one compatible IReadPropertyConverter contract"; + return false; + } + + var converterInterface = propertyMatches[0]; + binding = new GeneratedReadConverterBinding( + converterType.ToDisplayString(FullyQualifiedTypeFormat), + converterInterface.TypeArguments[0].ToDisplayString(FullyQualifiedTypeFormat), + converterInterface.TypeArguments[1].ToDisplayString(FullyQualifiedTypeFormat)); + return true; + } + + private static bool IsReadPropertyConverterInterface(INamedTypeSymbol type) + { + return type.OriginalDefinition.MetadataName == "IReadPropertyConverter`2" && + type.OriginalDefinition.ContainingNamespace.ToDisplayString() == MappingNamespace; + } + + private static bool CanAssignValue(ITypeSymbol targetType, ITypeSymbol valueType) + { + return IsSameOrNullableEquivalent(targetType, valueType) || IsAssignableFrom(targetType, valueType); + } + + private static bool IsSameOrNullableEquivalent(ITypeSymbol left, ITypeSymbol right) + { + return SymbolEqualityComparer.Default.Equals(left, right) || + SymbolEqualityComparer.Default.Equals(UnwrapNullable(left), right) || + SymbolEqualityComparer.Default.Equals(UnwrapNullable(right), left); + } + private static bool IsEntityMapInterface(INamedTypeSymbol type) { return type.OriginalDefinition.MetadataName == "IEntityMap`1" && @@ -1494,7 +1820,8 @@ private MapCandidate( Location location, string skipReason, GeneratedMaterializerInfo materializer, - string materializerSkipReason) + string materializerSkipReason, + GeneratedDiagnostic materializerDiagnostic) { Kind = kind; MapDisplayName = mapDisplayName; @@ -1506,6 +1833,7 @@ private MapCandidate( SkipReason = skipReason; Materializer = materializer; MaterializerSkipReason = materializerSkipReason; + MaterializerDiagnostic = materializerDiagnostic; } internal MapCandidateKind Kind { get; } @@ -1530,6 +1858,8 @@ private MapCandidate( internal string MaterializerSkipReason { get; } + internal GeneratedDiagnostic MaterializerDiagnostic { get; } + internal static MapCandidate Valid( string mapDisplayName, string mapTypeName, @@ -1538,7 +1868,8 @@ internal static MapCandidate Valid( int entityInheritanceDepth, Location location, GeneratedMaterializerInfo materializer, - string materializerSkipReason) + string materializerSkipReason, + GeneratedDiagnostic materializerDiagnostic) { return new MapCandidate( MapCandidateKind.Valid, @@ -1550,7 +1881,8 @@ internal static MapCandidate Valid( location, null, materializer, - materializerSkipReason); + materializerSkipReason, + materializerDiagnostic); } internal static MapCandidate InvalidRegistration(string mapDisplayName, Location location) @@ -1565,6 +1897,7 @@ internal static MapCandidate InvalidRegistration(string mapDisplayName, Location location, null, null, + null, null); } @@ -1580,6 +1913,7 @@ internal static MapCandidate Skipped(string mapDisplayName, Location location, s location, reason, null, + null, null); } } @@ -1591,13 +1925,65 @@ private enum MapCandidateKind Skipped } + private sealed class GeneratedDiagnostic + { + private GeneratedDiagnostic(DiagnosticDescriptor descriptor, Location location, object[] arguments) + { + Descriptor = descriptor; + Location = location; + Arguments = arguments; + } + + internal DiagnosticDescriptor Descriptor { get; } + + internal Location Location { get; } + + internal object[] Arguments { get; } + + internal static GeneratedDiagnostic InvalidReadConverter( + Location location, + string mapTypeName, + string converterTypeName, + string reason) + { + return new GeneratedDiagnostic( + InvalidGeneratedReadConverterRule, + location, + new object[] { mapTypeName, converterTypeName, reason }); + } + } + + private sealed class GeneratedReadConverterBinding + { + internal GeneratedReadConverterBinding( + string converterTypeName, + string databaseTypeName, + string propertyTypeName) + { + ConverterTypeName = converterTypeName; + DatabaseTypeName = databaseTypeName; + PropertyTypeName = propertyTypeName; + } + + internal string ConverterTypeName { get; } + + internal string DatabaseTypeName { get; } + + internal string PropertyTypeName { get; } + } + private sealed class GeneratedMapInvocation { - internal GeneratedMapInvocation(GeneratedMemberPath memberPath, string columnName, bool ignored) + internal GeneratedMapInvocation( + GeneratedMemberPath memberPath, + string columnName, + bool ignored, + GeneratedReadConverterBinding readConverter) { MemberPath = memberPath; ColumnName = columnName; Ignored = ignored; + ReadConverter = readConverter; } internal GeneratedMemberPath MemberPath { get; } @@ -1605,6 +1991,8 @@ internal GeneratedMapInvocation(GeneratedMemberPath memberPath, string columnNam internal string ColumnName { get; } internal bool Ignored { get; } + + internal GeneratedReadConverterBinding ReadConverter { get; } } private sealed class GeneratedMemberPath @@ -1676,11 +2064,16 @@ internal GeneratedMaterializerInfo WithMethodName(string methodName) private sealed class GeneratedColumnBinding { - internal GeneratedColumnBinding(string columnName, string memberPath, bool ignored) + internal GeneratedColumnBinding( + string columnName, + string memberPath, + bool ignored, + GeneratedReadConverterBinding readConverter) { ColumnName = columnName; MemberPath = memberPath; Ignored = ignored; + ReadConverter = readConverter; } internal string ColumnName { get; } @@ -1688,6 +2081,8 @@ internal GeneratedColumnBinding(string columnName, string memberPath, bool ignor internal string MemberPath { get; } internal bool Ignored { get; } + + internal GeneratedReadConverterBinding ReadConverter { get; } } private sealed class GeneratedPropertyBinding @@ -1699,7 +2094,8 @@ internal GeneratedPropertyBinding( string propertyName, string typeName, bool hasPublicSetter, - ITypeSymbol propertyTypeSymbol) + ITypeSymbol propertyTypeSymbol, + GeneratedReadConverterBinding readConverter) { Ordinal = ordinal; ColumnName = columnName; @@ -1708,6 +2104,7 @@ internal GeneratedPropertyBinding( TypeName = typeName; HasPublicSetter = hasPublicSetter; PropertyTypeSymbol = propertyTypeSymbol; + ReadConverter = readConverter; } internal int Ordinal { get; } @@ -1725,6 +2122,8 @@ internal GeneratedPropertyBinding( internal bool CanAssign => HasPublicSetter; internal ITypeSymbol PropertyTypeSymbol { get; } + + internal GeneratedReadConverterBinding ReadConverter { get; } } private sealed class GeneratedMaterializationNode @@ -1882,6 +2281,11 @@ internal IEnumerable GetColumnNames() return _leaves.Select(leaf => leaf.ColumnName) .Concat(_children.SelectMany(child => child.GetColumnNames())); } + + internal IEnumerable GetLeaves() + { + return _leaves.Concat(_children.SelectMany(child => child.GetLeaves())); + } } private sealed class GeneratedConstructorBinding diff --git a/src/Dapper.FluentMap/MappingRegistry.cs b/src/Dapper.FluentMap/MappingRegistry.cs index a117780..d60b48a 100644 --- a/src/Dapper.FluentMap/MappingRegistry.cs +++ b/src/Dapper.FluentMap/MappingRegistry.cs @@ -511,7 +511,9 @@ private bool GeneratedMaterializerMatchesEffectiveMapping( return false; } - if (PropertyMapConversion.GetConversion(fluentMap).HasReadConverter) + if (!GeneratedReadConverterMatchesEffectiveMapping( + PropertyMapConversion.GetConversion(fluentMap), + column)) { return false; } @@ -543,11 +545,33 @@ private bool GeneratedMaterializerMatchesEffectiveMapping( { return false; } + + if (column.ReadConverterType != null) + { + return false; + } } return true; } + private static bool GeneratedReadConverterMatchesEffectiveMapping( + PropertyConversionMetadata conversion, + GeneratedMaterializerColumn column) + { + if (conversion == null || !conversion.HasReadConverter) + { + return column.ReadConverterType == null && + column.ReadConverterDatabaseType == null && + column.ReadConverterPropertyType == null; + } + + var readConverter = conversion.ReadConverter; + return column.ReadConverterType == readConverter.ConverterType && + column.ReadConverterDatabaseType == readConverter.DatabaseType && + column.ReadConverterPropertyType == readConverter.PropertyType; + } + private void InvalidateType(Type type) { foreach (var key in _propertyMapCache.Keys.Where(k => k.Type == type)) diff --git a/src/Dapper.FluentMap/Materialization/GeneratedMaterializerColumn.cs b/src/Dapper.FluentMap/Materialization/GeneratedMaterializerColumn.cs index 46fd177..be97ea6 100644 --- a/src/Dapper.FluentMap/Materialization/GeneratedMaterializerColumn.cs +++ b/src/Dapper.FluentMap/Materialization/GeneratedMaterializerColumn.cs @@ -7,7 +7,13 @@ namespace Dapper.FluentMap.Materialization /// public sealed class GeneratedMaterializerColumn { - private GeneratedMaterializerColumn(string columnName, string memberPath, bool ignored) + private GeneratedMaterializerColumn( + string columnName, + string memberPath, + bool ignored, + Type readConverterType, + Type readConverterDatabaseType, + Type readConverterPropertyType) { if (string.IsNullOrWhiteSpace(columnName)) { @@ -22,6 +28,9 @@ private GeneratedMaterializerColumn(string columnName, string memberPath, bool i ColumnName = columnName; MemberPath = memberPath; Ignored = ignored; + ReadConverterType = readConverterType; + ReadConverterDatabaseType = readConverterDatabaseType; + ReadConverterPropertyType = readConverterPropertyType; } /// @@ -39,6 +48,21 @@ private GeneratedMaterializerColumn(string columnName, string memberPath, bool i /// public bool Ignored { get; } + /// + /// Gets the read converter type applied by the generated materializer, or . + /// + public Type ReadConverterType { get; } + + /// + /// Gets the database/provider CLR type accepted by the generated read converter, or . + /// + public Type ReadConverterDatabaseType { get; } + + /// + /// Gets the property CLR type returned by the generated read converter, or . + /// + public Type ReadConverterPropertyType { get; } + /// /// Creates a descriptor for a materialized column. /// @@ -47,7 +71,53 @@ private GeneratedMaterializerColumn(string columnName, string memberPath, bool i /// The generated materializer column descriptor. public static GeneratedMaterializerColumn Map(string columnName, string memberPath) { - return new GeneratedMaterializerColumn(columnName, memberPath, ignored: false); + return new GeneratedMaterializerColumn( + columnName, + memberPath, + ignored: false, + readConverterType: null, + readConverterDatabaseType: null, + readConverterPropertyType: null); + } + + /// + /// Creates a descriptor for a materialized column with a generated read converter. + /// + /// The column name expected at this ordinal. + /// The member path materialized from the column. + /// The converter type applied by the generated materializer. + /// The database/provider CLR type accepted by the converter. + /// The property CLR type returned by the converter. + /// The generated materializer column descriptor. + public static GeneratedMaterializerColumn Map( + string columnName, + string memberPath, + Type readConverterType, + Type readConverterDatabaseType, + Type readConverterPropertyType) + { + if (readConverterType == null) + { + throw new ArgumentNullException(nameof(readConverterType)); + } + + if (readConverterDatabaseType == null) + { + throw new ArgumentNullException(nameof(readConverterDatabaseType)); + } + + if (readConverterPropertyType == null) + { + throw new ArgumentNullException(nameof(readConverterPropertyType)); + } + + return new GeneratedMaterializerColumn( + columnName, + memberPath, + ignored: false, + readConverterType, + readConverterDatabaseType, + readConverterPropertyType); } /// @@ -57,7 +127,13 @@ public static GeneratedMaterializerColumn Map(string columnName, string memberPa /// The generated materializer column descriptor. public static GeneratedMaterializerColumn Ignore(string columnName) { - return new GeneratedMaterializerColumn(columnName, memberPath: null, ignored: true); + return new GeneratedMaterializerColumn( + columnName, + memberPath: null, + ignored: true, + readConverterType: null, + readConverterDatabaseType: null, + readConverterPropertyType: null); } } } diff --git a/test/Dapper.FluentMap.AotSmoke/Program.cs b/test/Dapper.FluentMap.AotSmoke/Program.cs index 7299449..d7c1f29 100644 --- a/test/Dapper.FluentMap.AotSmoke/Program.cs +++ b/test/Dapper.FluentMap.AotSmoke/Program.cs @@ -135,6 +135,13 @@ static void AssertGeneratedQueryMappedMaterializer() { throw new InvalidOperationException("Generated Value Object QueryMapped materializer was not used correctly."); } + + var converted = connection.QueryMappedSingle( + "SELECT 'A' AS status;"); + if (converted.Status != AccountStatus.Active) + { + throw new InvalidOperationException("Generated property converter materializer was not used correctly."); + } } #endif @@ -219,3 +226,32 @@ public ValueObjectCustomerMap() Map(customer => customer.Cpf.Number).ToColumn("cpf"); } } + +public enum AccountStatus +{ + Unknown, + Active +} + +public sealed class ConvertedCustomer +{ + public AccountStatus Status { get; set; } +} + +public sealed class ConvertedCustomerMap : EntityMap +{ + public ConvertedCustomerMap() + { + Map(customer => customer.Status) + .ToColumn("status") + .ConvertFromDatabaseUsing(); + } +} + +public sealed class AccountStatusConverter : IReadPropertyConverter +{ + public AccountStatus ConvertFromDatabase(string value) + { + return value == "A" ? AccountStatus.Active : AccountStatus.Unknown; + } +} diff --git a/test/Dapper.FluentMap.GeneratedRegistration.Tests/GeneratedRegistrationIntegrationTests.cs b/test/Dapper.FluentMap.GeneratedRegistration.Tests/GeneratedRegistrationIntegrationTests.cs index 1d284eb..60a6486 100644 --- a/test/Dapper.FluentMap.GeneratedRegistration.Tests/GeneratedRegistrationIntegrationTests.cs +++ b/test/Dapper.FluentMap.GeneratedRegistration.Tests/GeneratedRegistrationIntegrationTests.cs @@ -198,6 +198,123 @@ public void GeneratedQueryMappedShouldMatchRuntimeFallbackForEquivalentComplexSh } } + [Fact] + [Trait("Category", "Integration")] + public void GeneratedQueryMappedShouldMatchRuntimeFallbackForReadConverters() + { + ResetMapper(); + + try + { + GeneratedConvertedCustomer generatedScalar; + GeneratedConvertedCustomer generatedNull; + GeneratedConvertedNestedCustomer generatedNested; + GeneratedConvertedImmutableCustomer generatedImmutable; + GeneratedConvertedValueObjectCustomer generatedValueObject; + GeneratedConvertedProfileCustomer generatedProfileDefault; + GeneratedConvertedProfileCustomer generatedProfileLegacy; + + FluentMapper.Initialize(configuration => configuration.AddGeneratedMappings()); + + using (var connection = OpenConnection()) + { + generatedScalar = connection.QueryMappedSingle( + "SELECT 31 AS customer_id, 'A' AS status, '42' AS optional_score;"); + generatedNull = connection.QueryMappedSingle( + "SELECT 32 AS customer_id, NULL AS status, NULL AS optional_score;"); + generatedNested = connection.QueryMappedSingle( + "SELECT '00123' AS billing_zip, '00456' AS shipping_zip;"); + generatedImmutable = connection.QueryMappedSingle( + "SELECT 'I' AS status;"); + generatedValueObject = connection.QueryMappedSingle( + "SELECT '12345678909' AS cpf;"); + generatedProfileDefault = connection.QueryMappedSingle( + "SELECT 'A' AS status;"); + generatedProfileLegacy = connection.QueryMappedSingle( + "SELECT '1' AS legacy_status;"); + + Assert.Equal(0, FluentMapper.Registry.MaterializationPlanCacheEntryCount); + } + + ResetMapper(); + + FluentMapper.Initialize(configuration => + { + configuration.AddMap(); + configuration.AddMap(); + configuration.AddMap(); + configuration.AddMap(); + configuration.AddMap(); + configuration.AddProfile(); + }); + + using (var connection = OpenConnection()) + { + var runtimeScalar = connection.QueryMappedSingle( + "SELECT 31 AS customer_id, 'A' AS status, '42' AS optional_score;"); + var runtimeNull = connection.QueryMappedSingle( + "SELECT 32 AS customer_id, NULL AS status, NULL AS optional_score;"); + var runtimeNested = connection.QueryMappedSingle( + "SELECT '00123' AS billing_zip, '00456' AS shipping_zip;"); + var runtimeImmutable = connection.QueryMappedSingle( + "SELECT 'I' AS status;"); + var runtimeValueObject = connection.QueryMappedSingle( + "SELECT '12345678909' AS cpf;"); + var runtimeProfileDefault = connection.QueryMappedSingle( + "SELECT 'A' AS status;"); + var runtimeProfileLegacy = connection.QueryMappedSingle( + "SELECT '1' AS legacy_status;"); + + Assert.Equal(runtimeScalar.Id, generatedScalar.Id); + Assert.Equal(runtimeScalar.Status, generatedScalar.Status); + Assert.Equal(runtimeScalar.OptionalScore, generatedScalar.OptionalScore); + Assert.Equal(runtimeNull.Id, generatedNull.Id); + Assert.Equal(runtimeNull.Status, generatedNull.Status); + Assert.Equal(runtimeNull.OptionalScore, generatedNull.OptionalScore); + Assert.Equal(runtimeNested.BillingAddress.ZipCode, generatedNested.BillingAddress.ZipCode); + Assert.Equal(runtimeNested.ShippingAddress.ZipCode, generatedNested.ShippingAddress.ZipCode); + Assert.Equal(runtimeImmutable.Status, generatedImmutable.Status); + Assert.Equal(runtimeValueObject.Cpf.Number, generatedValueObject.Cpf.Number); + Assert.Equal(runtimeProfileDefault.Status, generatedProfileDefault.Status); + Assert.Equal(runtimeProfileLegacy.Status, generatedProfileLegacy.Status); + } + } + finally + { + ResetMapper(); + } + } + + [Fact] + [Trait("Category", "Integration")] + public void GeneratedQueryMappedShouldWrapReadConverterExceptions() + { + ResetMapper(); + + try + { + FluentMapper.Initialize(configuration => configuration.AddGeneratedMappings()); + + using (var connection = OpenConnection()) + { + var exception = Assert.Throws( + () => connection.QueryMappedSingle( + "SELECT 'bad' AS status;")); + + Assert.IsType(exception.InnerException); + Assert.Contains(typeof(GeneratedThrowingConvertedCustomer).FullName, exception.Message); + Assert.Contains(nameof(GeneratedThrowingConvertedCustomer.Status), exception.Message); + Assert.Contains("status", exception.Message); + Assert.Contains(typeof(GeneratedThrowingStatusConverter).FullName, exception.Message); + Assert.Equal(0, FluentMapper.Registry.MaterializationPlanCacheEntryCount); + } + } + finally + { + ResetMapper(); + } + } + private static SqliteConnection OpenConnection() { var connection = new SqliteConnection("Data Source=:memory:"); @@ -221,7 +338,13 @@ private static void ResetMapper() typeof(GeneratedSameTerminalCustomer), typeof(GeneratedProfileNestedCustomer), typeof(GeneratedIgnoredCustomer), - typeof(GeneratedReadSemanticsCustomer)); + typeof(GeneratedReadSemanticsCustomer), + typeof(GeneratedConvertedCustomer), + typeof(GeneratedConvertedNestedCustomer), + typeof(GeneratedConvertedImmutableCustomer), + typeof(GeneratedConvertedValueObjectCustomer), + typeof(GeneratedConvertedProfileCustomer), + typeof(GeneratedThrowingConvertedCustomer)); } } @@ -502,4 +625,180 @@ public GeneratedReadSemanticsCustomerMap() Map(customer => customer.Secret).ToColumn("secret").Ignore(); } } + + public enum GeneratedAccountStatus + { + Unknown, + Active, + Inactive + } + + public sealed class GeneratedConvertedCustomer + { + public int Id { get; set; } + + public GeneratedAccountStatus Status { get; set; } + + public int? OptionalScore { get; set; } + } + + public sealed class GeneratedConvertedCustomerMap : EntityMap + { + public GeneratedConvertedCustomerMap() + { + Map(customer => customer.Id).ToColumn("customer_id"); + Map(customer => customer.Status).ToColumn("status").ConvertFromDatabaseUsing(); + Map(customer => customer.OptionalScore).ToColumn("optional_score").ConvertFromDatabaseUsing(); + } + } + + public sealed class GeneratedStatusConverter : IReadPropertyConverter + { + public GeneratedAccountStatus ConvertFromDatabase(string value) + { + return value == "A" ? GeneratedAccountStatus.Active : GeneratedAccountStatus.Unknown; + } + } + + public sealed class GeneratedScoreConverter : IReadPropertyConverter + { + public int ConvertFromDatabase(string value) + { + return int.Parse(value); + } + } + + public sealed class GeneratedConvertedNestedCustomer + { + public GeneratedConvertedAddress BillingAddress { get; set; } + + public GeneratedConvertedAddress ShippingAddress { get; set; } + } + + public sealed class GeneratedConvertedAddress + { + public string ZipCode { get; set; } + } + + public sealed class GeneratedConvertedNestedCustomerMap : EntityMap + { + public GeneratedConvertedNestedCustomerMap() + { + Map(customer => customer.BillingAddress.ZipCode) + .ToColumn("billing_zip") + .ConvertFromDatabaseUsing(); + Map(customer => customer.ShippingAddress.ZipCode).ToColumn("shipping_zip"); + } + } + + public sealed class GeneratedZipCodeConverter : IReadPropertyConverter + { + public string ConvertFromDatabase(string value) + { + return "ZIP-" + value; + } + } + + public sealed class GeneratedConvertedImmutableCustomer + { + public GeneratedConvertedImmutableCustomer(GeneratedAccountStatus status) + { + Status = status; + } + + public GeneratedAccountStatus Status { get; } + } + + public sealed class GeneratedConvertedImmutableCustomerMap : EntityMap + { + public GeneratedConvertedImmutableCustomerMap() + { + Map(customer => customer.Status).ToColumn("status").ConvertFromDatabaseUsing(); + } + } + + public sealed class GeneratedLegacyStatusConverter : IReadPropertyConverter + { + public GeneratedAccountStatus ConvertFromDatabase(string value) + { + return value == "1" || value == "I" + ? GeneratedAccountStatus.Inactive + : GeneratedAccountStatus.Active; + } + } + + public sealed class GeneratedConvertedValueObjectCustomer + { + public GeneratedConvertedCpf Cpf { get; set; } + } + + public sealed class GeneratedConvertedCpf + { + public GeneratedConvertedCpf(string number) + { + Number = number; + } + + public string Number { get; } + } + + public sealed class GeneratedConvertedValueObjectCustomerMap : EntityMap + { + public GeneratedConvertedValueObjectCustomerMap() + { + Map(customer => customer.Cpf).ToColumn("cpf").ConvertFromDatabaseUsing(); + } + } + + public sealed class GeneratedCpfConverter : IReadPropertyConverter + { + public GeneratedConvertedCpf ConvertFromDatabase(string value) + { + return new GeneratedConvertedCpf("converted:" + value); + } + } + + public sealed class GeneratedConvertedProfileCustomer + { + public GeneratedAccountStatus Status { get; set; } + } + + public sealed class GeneratedConvertedProfileCustomerMap : EntityMap + { + public GeneratedConvertedProfileCustomerMap() + { + Map(customer => customer.Status).ToColumn("status").ConvertFromDatabaseUsing(); + } + } + + public sealed class GeneratedConvertedLegacyProfileCustomerMap : + EntityMap, + IProfileMap + { + public GeneratedConvertedLegacyProfileCustomerMap() + { + Map(customer => customer.Status).ToColumn("legacy_status").ConvertFromDatabaseUsing(); + } + } + + public sealed class GeneratedThrowingConvertedCustomer + { + public GeneratedAccountStatus Status { get; set; } + } + + public sealed class GeneratedThrowingConvertedCustomerMap : EntityMap + { + public GeneratedThrowingConvertedCustomerMap() + { + Map(customer => customer.Status).ToColumn("status").ConvertFromDatabaseUsing(); + } + } + + public sealed class GeneratedThrowingStatusConverter : IReadPropertyConverter + { + public GeneratedAccountStatus ConvertFromDatabase(string value) + { + throw new InvalidOperationException("Invalid status."); + } + } } diff --git a/test/Dapper.FluentMap.Generators.Tests/MappingRegistrationGeneratorTests.cs b/test/Dapper.FluentMap.Generators.Tests/MappingRegistrationGeneratorTests.cs index bc30284..4e51c71 100644 --- a/test/Dapper.FluentMap.Generators.Tests/MappingRegistrationGeneratorTests.cs +++ b/test/Dapper.FluentMap.Generators.Tests/MappingRegistrationGeneratorTests.cs @@ -250,6 +250,131 @@ public CustomerMap() Assert.DoesNotContain("entity.Secret =", result.GeneratedSource, StringComparison.Ordinal); } + [Fact] + public void ReadConverterTypeShouldGenerateConvertedMaterializer() + { + var source = @" +using Dapper.FluentMap.Mapping; + +public sealed class Customer +{ + public Status Status { get; set; } +} + +public enum Status +{ + Unknown, + Active +} + +public sealed class StatusConverter : IReadPropertyConverter +{ + public Status ConvertFromDatabase(string value) + { + return value == ""A"" ? Status.Active : Status.Unknown; + } +} + +public sealed class CustomerMap : EntityMap +{ + public CustomerMap() + { + Map(customer => customer.Status) + .ToColumn(""status"") + .ConvertFromDatabaseUsing(); + } +}"; + + var result = RunGenerator(source); + + Assert.Empty(result.DfmDiagnostics); + Assert.Contains(".AddGeneratedMaterializer(", result.GeneratedSource, StringComparison.Ordinal); + Assert.Contains("GeneratedMaterializerColumn.Map(\"status\", \"Status\", typeof(global::StatusConverter), typeof(string), typeof(global::Status))", result.GeneratedSource, StringComparison.Ordinal); + Assert.Contains("private static readonly global::StatusConverter Read0Converter0 = new global::StatusConverter();", result.GeneratedSource, StringComparison.Ordinal); + Assert.Contains("entity.Status = ReadConverted(record, 0, Read0Converter0", result.GeneratedSource, StringComparison.Ordinal); + } + + [Fact] + public void ReadConverterInstanceShouldUseRuntimeMaterializerFallback() + { + var source = @" +using Dapper.FluentMap.Mapping; + +public sealed class Customer +{ + public string Name { get; set; } +} + +public sealed class NameConverter : IReadPropertyConverter +{ + public string ConvertFromDatabase(string value) + { + return value; + } +} + +public sealed class CustomerMap : EntityMap +{ + public CustomerMap() + { + Map(customer => customer.Name) + .ToColumn(""name"") + .ConvertFromDatabaseUsing(new NameConverter()); + } +}"; + + var result = RunGenerator(source); + var diagnostic = Assert.Single(result.DfmDiagnostics); + + Assert.Equal(MappingRegistrationGenerator.SkippedGeneratedMaterializerDiagnosticId, diagnostic.Id); + Assert.Contains("instances and delegates", diagnostic.GetMessage(), StringComparison.Ordinal); + Assert.Contains(".AddMap()", result.GeneratedSource, StringComparison.Ordinal); + Assert.DoesNotContain(".AddGeneratedMaterializer(", result.GeneratedSource, StringComparison.Ordinal); + } + + [Fact] + public void InvalidReadConverterContractShouldReportDiagnostic() + { + var source = @" +using Dapper.FluentMap.Mapping; + +public sealed class Customer +{ + public Status Status { get; set; } +} + +public enum Status +{ + Unknown, + Active +} + +public sealed class InvalidStatusConverter : IReadPropertyConverter +{ + public string ConvertFromDatabase(int value) + { + return value.ToString(); + } +} + +public sealed class CustomerMap : EntityMap +{ + public CustomerMap() + { + Map(customer => customer.Status) + .ToColumn(""status"") + .ConvertFromDatabaseUsing(); + } +}"; + + var result = RunGenerator(source, assertCompiles: false); + var diagnostic = Assert.Single(result.DfmDiagnostics); + + Assert.Equal(MappingRegistrationGenerator.InvalidGeneratedReadConverterDiagnosticId, diagnostic.Id); + Assert.Equal(DiagnosticSeverity.Error, diagnostic.Severity); + Assert.Contains("cannot be assigned", diagnostic.GetMessage(), StringComparison.Ordinal); + } + [Fact] public void MultipleMappingsShouldBeGeneratedInDeterministicOrder() { diff --git a/test/Dapper.FluentMap.Tests/PropertyConversionMetadataTests.cs b/test/Dapper.FluentMap.Tests/PropertyConversionMetadataTests.cs index b97415e..db66f0b 100644 --- a/test/Dapper.FluentMap.Tests/PropertyConversionMetadataTests.cs +++ b/test/Dapper.FluentMap.Tests/PropertyConversionMetadataTests.cs @@ -283,6 +283,44 @@ public void GeneratedMaterializerShouldNotMatchReadConverterMetadata() } } + [Fact] + public void GeneratedMaterializerShouldMatchDeclaredReadConverterMetadata() + { + PreTest(typeof(ConversionEntity)); + + try + { + FluentMapper.Initialize(c => + { + c.AddMap(new ReadOnlyConversionMap()); + c.AddGeneratedMaterializer( + new[] + { + GeneratedMaterializerColumn.Map( + "status", + nameof(ConversionEntity.Status), + typeof(StatusReadConverter), + typeof(string), + typeof(AccountStatus)) + }, + ReadGeneratedConversionEntity); + }); + + var found = FluentMapper.Registry.TryGetGeneratedMaterializer( + typeof(ConversionEntity), + profileType: null, + columnNames: new[] { "status" }, + out var materializer); + + Assert.True(found); + Assert.NotNull(materializer); + } + finally + { + PreTest(typeof(ConversionEntity)); + } + } + private static PropertyConversionMetadata ConversionOf(IEntityMap map) { return ((IPropertyMapWithConversionMetadata)map.PropertyMaps.Single()).Conversion; From 05cf319a5776e139246ea99a44bbbb929e449ccb Mon Sep 17 00:00:00 2001 From: Rodrigo de Oliveira Date: Tue, 28 Jul 2026 17:46:11 -0300 Subject: [PATCH 26/49] docs(sdd): document dommel write conversion boundary --- .sdd/etapa-10/07-write-conversion.md | 221 +++++++++++++++++++++++++++ .sdd/etapa-10/DECISIONS.md | 47 ++++++ .sdd/etapa-10/STATUS.md | 48 +++++- 3 files changed, 312 insertions(+), 4 deletions(-) create mode 100644 .sdd/etapa-10/07-write-conversion.md diff --git a/.sdd/etapa-10/07-write-conversion.md b/.sdd/etapa-10/07-write-conversion.md new file mode 100644 index 0000000..7770bf5 --- /dev/null +++ b/.sdd/etapa-10/07-write-conversion.md @@ -0,0 +1,221 @@ +# Etapa 10 - Write Conversion & Dommel Boundary + +## Objetivo do Prompt 10.5 + +Investigar se a conversao: + +```text +Property CLR value -> Database/provider CLR value +``` + +deve e pode ser executada na Etapa 10 pela integracao opcional com Dommel, sem +fazer o core executar CRUD e sem usar reflection privada ou copiar internals do +Dommel. + +## Resultado arquitetural + +A Etapa 10.5 nao implementa execucao de write converters no Dommel. + +O motivo e tecnico e de fronteira publica: o Dommel 3.5.3 usado pelo projeto +expoe resolvers para tabela, coluna, chave, propriedades e SQL builders, mas os +metodos publicos `Insert` e `Update` continuam executando o comando passando a +propria entidade como objeto de parametros para o Dapper. + +Na fonte publica mais proxima da versao atual, o fluxo e: + +```text +Dommel Insert/Update + -> resolve table/properties/keys/columns + -> build SQL + -> connection.ExecuteScalar/Execute(sql, entity, ...) + -> Dapper cria parametros a partir das propriedades da entidade +``` + +Esses extension points permitem excluir ou incluir propriedades/colunas e +alterar SQL gerado, mas nao permitem trocar o valor de `DbParameter.Value` por +propriedade antes de o Dapper materializar os parametros. + +## Write converter + +O contrato publico de escrita ja existe no core: + +```csharp +public interface IWritePropertyConverter +{ + TDatabase ConvertToDatabase(TProperty value); +} +``` + +Sua direcao e: + +```text +Property CLR value -> Database/provider CLR value +``` + +Na Etapa 10.5, esse contrato permanece metadata descritiva. Ele nao e executado +por `Dapper.FluentMap.Dommel`. + +## Persistence metadata + +A decisao preserva integralmente a Etapa 8. A participacao de propriedades em +persistencia continua determinada por `PropertyPersistenceMetadata` e pela +integracao Dommel ja existente. + +| Semantica | Insert | Update | Write converter na Etapa 10.5 | +| --- | --- | --- | --- | +| Normal | participa | participa | nao executado | +| ReadOnly | omitido | omitido | nao executado | +| Computed | omitido | omitido | nao executado | +| Generated default on insert | omitido | participa, salvo exclusao | nao executado | +| Identity key | omitido | WHERE only | nao executado | +| Non-identity key | participa | WHERE only | nao executado | +| ExcludeFromInsert | omitido | participa | nao executado | +| ExcludeFromUpdate | participa | omitido do SET | nao executado | +| Ignore | omitido | omitido | nao executado | + +Mesmo quando write conversion vier a ser suportada, a regra deve continuar: +propriedades que nao participam da operacao nao podem chamar converter nessa +operacao. + +## Insert + +Para `INSERT`, a integracao atual com Dommel controla a lista efetiva de +propriedades por `DommelPropertyResolver` e `DommelPersistenceSqlBuilder`. + +O SQL builder consegue recompor colunas e nomes de parametros, mas os valores +ainda vem da entidade original passada ao Dapper pelo Dommel. Alterar o SQL para +outro nome de parametro nao resolve a conversao, pois a entidade nao expoe uma +propriedade com o valor convertido. + +Portanto, `ConvertToDatabaseUsing(...)` nao e chamado em `connection.Insert(...)` +na Etapa 10.5. + +## Update + +Para `UPDATE`, a integracao atual usa metadata de geracao para manter fora do +`SET` propriedades que nao participam de update. O valor de cada parametro do +`SET` e das chaves do `WHERE` ainda e lido pelo Dapper da entidade original. + +Portanto, `ConvertToDatabaseUsing(...)` tambem nao e chamado em +`connection.Update(...)` na Etapa 10.5. + +## Null + +Como a escrita nao e executada nesta etapa, nao ha nova semantica de null em +Dommel. + +A regra futura recomendada permanece alinhada com read conversion: + +```text +null nao deve ser enviado ao converter por default + -> parametro recebe null/DBNull conforme Dapper/provider +``` + +Isso evita converter null em valores sentinela sem opt-in explicito e preserva a +semantica de bancos relacionais. + +## Parameters + +Nao foi adicionado wrapper de parametros, `DynamicParameters`, parameter +metadata ou `DbType`. + +Uma implementacao futura precisa de um dos caminhos abaixo: + +- hook publico do Dommel para transformar valores por propriedade antes da + execucao; +- API publica propria no pacote Dommel integration que delegue resolucao de SQL + ao Dommel, mas receba parametros convertidos de forma explicita; +- mudanca upstream no Dommel para aceitar um parameter/value resolver. + +A primeira opcao e preferivel porque preserva `connection.Insert(...)` e +`connection.Update(...)` como responsabilidade do Dommel. + +## TypeHandler interaction + +Sem write converter executado: + +```text +Dommel -> Dapper parameterization -> Dapper TypeHandler/provider +``` + +continua sendo o comportamento efetivo para `Insert` e `Update`, igual ao +comportamento anterior. + +Quando write conversion for implementada, a precedencia especificada deve ser: + +```text +property write converter + -> final database/provider CLR value +``` + +Nesse caminho, o FluentMap nao deve aplicar: + +```text +PropertyConverter -> TypeHandler +``` + +em sequencia. Um converter por propriedade e uma decisao local explicita e deve +produzir o valor de parametro final no nivel CLR. Sem converter por propriedade, +`TypeHandler` continua sendo o mecanismo global recomendado. + +Se o valor convertido tiver seu proprio tipo CLR com `TypeHandler`, +qualquer uso por Dapper precisa ser tratado como extensao futura explicita e +testada; a Etapa 10.5 nao define essa composicao. + +## DbType + +Nao ha mapeamento generico de `DbType` nesta etapa. + +Se um converter exigir `DbType` especifico para diferenciar, por exemplo, +`string` ANSI/Unicode, tamanho, precision/scale ou tipos provider-specific, isso +deve ser modelado como extensao futura de parameter metadata. Esse problema nao +deve ser escondido dentro de `IWritePropertyConverter`. + +## Profiles + +Profiles seguem sendo maps separados para materializacao/query shapes. A Etapa +10 nao deve forcar write profiles apenas por simetria. + +Como Dommel `Insert` e `Update` atuais operam sobre a entidade/tipo, sem API +publica de profile para persistencia, write converters profile-specific nao sao +executados nem introduzidos nesta etapa. + +Uma decisao futura de profiles de escrita precisa primeiro definir: + +- como o consumidor escolhe um profile em uma operacao Dommel de escrita; +- se o profile altera somente conversao ou tambem participacao de colunas; +- como isso interage com cache de SQL e resolvers globais do Dommel. + +## Test strategy + +Os cenarios pedidos para write conversion continuam sendo requisitos para a +implementacao futura: + +- insert normal; +- update normal; +- converter; +- null; +- read-only; +- computed; +- generated; +- identity; +- non-identity key; +- exclude insert; +- exclude update; +- coexistencia com `TypeHandler`; +- falha de converter. + +Na Etapa 10.5, eles nao foram adicionados como testes de execucao porque seria +necessario implementar um caminho de escrita nao suportado pelos extension +points publicos atuais do Dommel. Os testes aplicaveis sao os de regressao de +persistence metadata Dommel, garantindo que a decisao nao alterou insert/update +existentes. + +## Limitacao registrada + +O suporte completo a write conversion fica bloqueado ate existir um hook publico +de valores de parametros por propriedade ou uma API publica propria e explicita +que nao se confunda com os metodos `Insert`/`Update` do Dommel. + +Nao houve mudanca comportamental em `connection.Insert(...)`, +`connection.Update(...)`, `InsertAll(...)` ou variantes async. diff --git a/.sdd/etapa-10/DECISIONS.md b/.sdd/etapa-10/DECISIONS.md index 2a26feb..230e466 100644 --- a/.sdd/etapa-10/DECISIONS.md +++ b/.sdd/etapa-10/DECISIONS.md @@ -378,3 +378,50 @@ alvo real da propriedade/parametro, preservando equivalencia para casos como O novo diagnostic `DFM012` reporta contrato read invalido quando isso pode ser provado em compile-time. Fallback continua sendo uma limitacao de otimizacao, nao breaking change para cenarios suportados pelo runtime. + +## ADR-14 - Prompt 10.5 Dommel write conversion boundary + +### Contexto + +O Prompt 10.5 pediu conversao de escrita `Property -> Database value` quando a +arquitetura da Etapa 10 determinasse que isso e responsabilidade do FluentMap, +com Dommel como consumidor inicial. A restricao principal era usar extension +points suportados pela versao atual do Dommel, sem reflection privada e sem +copiar internals. + +Dommel 3.5.3 expoe resolvers de tabela, coluna, chave, propriedades e +`ISqlBuilder`. Esses pontos permitem alterar metadata e SQL, mas `Insert` e +`Update` executam passando a entidade original ao Dapper como objeto de +parametros. + +### Decisao + +Write conversion permanece especificada como metadata no core, mas nao e +executada pela integracao Dommel na Etapa 10.5. + +Nao sera criado wrapper implicito de parametros nem nova API de CRUD nesta etapa. +Tambem nao sera feita composicao implicita `PropertyConverter -> TypeHandler`. + +### Alternativas consideradas + +- Usar `ISqlBuilder` para trocar nomes de parametros: rejeitado porque o Dapper + ainda le os valores da entidade original e nao ha propriedades convertidas. +- Copiar a montagem de `Insert`/`Update` do Dommel: rejeitado por duplicar + responsabilidade de CRUD/SQL generation e aumentar risco de divergencia. +- Reflection privada sobre caches/internals do Dommel: rejeitada por fragilidade + e incompatibilidade com o requisito. +- Mutar a entidade antes da chamada e restaurar depois: rejeitado por side + effects, thread safety e excecoes intermediarias. +- Confiar em `TypeHandler`: preservado como fallback global, mas nao + resolve conversao property-scoped. + +### Consequencias + +`connection.Insert(...)` e `connection.Update(...)` continuam respeitando +persistence metadata da Etapa 8, mas nao chamam write converters. Sem converter +executado, `TypeHandler` e provider continuam com o mesmo papel que +tinham antes. + +O suporte futuro depende de um hook publico de parametros por propriedade no +Dommel ou de uma API explicita no pacote de integracao que deixe claro que usa +parametros convertidos e nao os metodos Dommel existentes diretamente. diff --git a/.sdd/etapa-10/STATUS.md b/.sdd/etapa-10/STATUS.md index 8e4a399..cfff601 100644 --- a/.sdd/etapa-10/STATUS.md +++ b/.sdd/etapa-10/STATUS.md @@ -71,6 +71,7 @@ tipo e abrindo espaco para conversao por propriedade, map e profile. TypeHandler e property converter. - Criado `.sdd/etapa-10/05-performance-baseline.md`. - Criado `.sdd/etapa-10/06-generated-conversion.md`. +- Criado `.sdd/etapa-10/07-write-conversion.md`. - Generated materializers passam a emitir property read converters por tipo quando o converter e estaticamente suportado. - `GeneratedMaterializerColumn` passou a declarar metadata opcional de read @@ -86,15 +87,22 @@ tipo e abrindo espaco para conversao por propriedade, map e profile. - Converters por instancia/delegate e converters inacessiveis ao codigo gerado continuam usando runtime fallback. - Smoke AOT generated atualizado para cobrir property read converter. +- Investigado Dommel 3.5.3 para write conversion e confirmado que os extension + points publicos atuais nao expoem hook de valor de parametro por propriedade. +- Documentada a decisao de nao executar write converters em Dommel nesta etapa, + preservando persistence semantics da Etapa 8 e comportamento atual de + `Insert`/`Update`. ## Em andamento -Write/Dommel conversion permanece adiada para incremento seguinte. +Write/Dommel conversion permanece bloqueada ate existir um hook publico de +parametros por propriedade no Dommel ou uma API explicita no pacote de +integracao. ## Proximos passos -1. Investigar e implementar write conversion/Dommel somente apos definir hook - de parametros por propriedade. +1. Definir hook publico de parametros por propriedade antes de implementar write + conversion/Dommel. 2. Evoluir diagnostics/analyzers alem do generator para reconhecer `Convert...`. 3. Aumentar benchmark formal quando houver decisao de otimizacao. @@ -121,6 +129,9 @@ Write/Dommel conversion permanece adiada para incremento seguinte. - Prompt 10.4 executa read converters no generated materializer somente para converters por tipo estaticamente suportados e mantem fallback runtime para instancia/delegate/inacessivel. +- Prompt 10.5 mantem write converters como metadata-only para Dommel porque + `Insert`/`Update` passam a entidade original ao Dapper e a versao atual nao + expoe hook publico para substituir valores de parametros por propriedade. ## APIs implementadas no Prompt 10.2 @@ -181,6 +192,9 @@ public interface IPropertyConverter : interfaces em configuration time; overloads por instancia/delegate oferecem caminho mais favoravel a AOT. - TypeHandler no generated path permanece fora do escopo. +- Write converters em Dommel permanecem metadata-only: `Insert`/`Update` do + Dommel passam a entidade original ao Dapper e nao expoem hook publico para + substituir `DbParameter.Value` por propriedade. ## Validacao do Prompt 10.1 @@ -257,6 +271,18 @@ public interface IPropertyConverter : foram emitidos warnings esperados `IL2026` e `IL3050` nas chamadas `QueryMapped*`. +## Validacao do Prompt 10.5 + +- `dotnet restore ./Dapper.FluentMap.sln`: sucesso. +- `dotnet build ./Dapper.FluentMap.sln --configuration Release --no-restore`: + sucesso, 0 warnings, 0 errors. +- `dotnet test ./Dapper.FluentMap.sln --configuration Release --no-build`: + sucesso, 391 testes aprovados no total. +- `dotnet test ./test/Dapper.FluentMap.Dommel.Tests/Dapper.FluentMap.Dommel.Tests.csproj --configuration Release --no-build`: + sucesso, 21 testes Dommel aprovados. +- `dotnet pack`: nao executado; este prompt alterou somente documentacao SDD e + nao mudou empacotamento ou codigo produtivo. + ## Interacao com Dapper TypeHandler Precedencia proposta para `QueryMapped*`: @@ -275,6 +301,19 @@ property write converter -> Dapper/provider parameter default ``` +Apos Prompt 10.5, essa precedencia continua especificacao futura, nao +comportamento Dommel implementado. No Dommel atual, sem write converter +executado, o fluxo permanece: + +```text +Dommel Insert/Update + -> Dapper parameterization da entidade original + -> Dapper TypeHandler/provider +``` + +Quando write conversion for implementada, nao deve haver composicao implicita +`PropertyConverter -> TypeHandler` na mesma propriedade. + APIs normais do Dapper continuam fora do controle property-scoped do FluentMap: ```text @@ -291,6 +330,7 @@ connection.Query() - `.sdd/etapa-10/04-runtime-conversion.md` - `.sdd/etapa-10/05-performance-baseline.md` - `.sdd/etapa-10/06-generated-conversion.md` +- `.sdd/etapa-10/07-write-conversion.md` - `.sdd/etapa-10/DECISIONS.md` - `.sdd/etapa-10/STATUS.md` - `src/Dapper.FluentMap/Materialization/NestedMaterializationPlan.cs` @@ -316,4 +356,4 @@ connection.Query() ## Ultimo prompt executado -Ultimo prompt executado: 10.4 +Ultimo prompt executado: 10.5 From 7ecd095e96bafa85e44c6df912d5a090db24baf7 Mon Sep 17 00:00:00 2001 From: Rodrigo de Oliveira Date: Wed, 29 Jul 2026 06:48:25 -0300 Subject: [PATCH 27/49] feat(diagnostics): validate property converter configuration --- .sdd/etapa-10/05-performance-baseline.md | 43 +++ .sdd/etapa-10/08-conversion-diagnostics.md | 147 +++++++++ .sdd/etapa-10/DECISIONS.md | 39 +++ .sdd/etapa-10/STATUS.md | 58 +++- .../AnalyzerReleases.Unshipped.md | 4 +- .../FluentMapConfigurationAnalyzer.cs | 304 +++++++++++++++++- src/Dapper.FluentMap.Analyzers/README.md | 2 +- .../Mapping/PropertyConversionMetadata.cs | 9 + .../MappingConfigurationValidator.cs | 124 +++++++ .../FluentMapConfigurationAnalyzerTests.cs | 157 ++++++++- .../DommelPersistenceIntegrationTests.cs | 73 +++++ .../GeneratedRegistrationIntegrationTests.cs | 40 +++ .../ConfigurationValidationTests.cs | 81 +++++ .../RuntimeReadConversionTests.cs | 157 ++++++++- 14 files changed, 1222 insertions(+), 16 deletions(-) create mode 100644 .sdd/etapa-10/08-conversion-diagnostics.md diff --git a/.sdd/etapa-10/05-performance-baseline.md b/.sdd/etapa-10/05-performance-baseline.md index 14f1945..b9f54df 100644 --- a/.sdd/etapa-10/05-performance-baseline.md +++ b/.sdd/etapa-10/05-performance-baseline.md @@ -116,3 +116,46 @@ Interpretacao: gerado apareceu mais lento nesta unica iteracao. - A evidencia funcional mais importante do prompt continua sendo equivalencia `runtime == generated` e cache runtime zerado no caminho gerado. + +## Apos Prompt 10.6 + +O Prompt 10.6 adicionou diagnostics, validacao runtime e testes de hardening +sem alterar o hot path de conversao. Foi executado benchmark curto em +2026-07-29 com o mesmo perfil Dry representativo: + +```powershell +dotnet run --configuration Release --project .\benchmarks\Dapper.FluentMap.Benchmarks\Dapper.FluentMap.Benchmarks.csproj -- --filter "*MaterializationSteadyStateBenchmarks.QueryMapped*Converter*" --job Dry --warmupCount 1 --minIterationCount 1 --maxIterationCount 2 +``` + +Ambiente reportado: + +```text +BenchmarkDotNet v0.15.8 +Windows 11 25H2 +.NET SDK 10.0.302 +.NET Runtime 10.0.10 +Intel Core i5-1145G7 +``` + +Resultado observado: + +| Method | Mean | Allocated | +|---|---:|---:| +| QueryMappedRuntimePropertyConverter | 1.095 ms | 165.98 KB | +| QueryMappedRuntimeSimpleConverter | 1.190 ms | 189.43 KB | +| QueryMappedGeneratedPropertyConverter | 1.203 ms | 166.55 KB | +| QueryMappedGeneratedSimpleConverter | 1.295 ms | 189.99 KB | +| QueryMappedRuntimeNoConverter | 1.805 ms | 142.55 KB | + +Interpretacao: + +- A execucao continua sendo smoke de performance. BenchmarkDotNet alertou que + todos os tempos de iteracao ficaram abaixo de 100 ms. +- A validacao e os analyzers novos atuam em configuracao/compilacao, fora do + custo por linha do materializer. +- O custo fixo de converter permanece na criacao de metadata/plano ou no campo + estatico gerado. O custo por linha segue sendo a chamada do converter e, se + necessario, a conversao do valor bruto para `TDatabase`. +- As alocacoes ficaram alinhadas com a baseline anterior: generated converter + adiciona diferenca pequena de descriptor/caminho gerado, e runtime converter + nao introduziu nova alocacao observavel por linha nesta medicao curta. diff --git a/.sdd/etapa-10/08-conversion-diagnostics.md b/.sdd/etapa-10/08-conversion-diagnostics.md new file mode 100644 index 0000000..98aa561 --- /dev/null +++ b/.sdd/etapa-10/08-conversion-diagnostics.md @@ -0,0 +1,147 @@ +# Etapa 10 - Conversion Diagnostics & Hardening + +## Objetivo do Prompt 10.6 + +Consolidar property converters sem adicionar uma segunda geracao de features. +O foco deste incremento e separar: + +- erros comprovaveis em compile time; +- validacao runtime da configuracao efetiva; +- diagnosticos gerados pelo source generator; +- limites documentados de extensibilidade, concorrencia e Dommel/write. + +Analyzers nao executam construtores de maps. `FluentMapper.Validate()` continua +sendo a validacao da composicao efetiva registrada em runtime. + +## Diagnostic specification + +| Condition | Compile time | Runtime validation | Severity | Diagnostic | +| --------- | ------------ | ------------------ | -------- | ---------- | +| incompatible converter por tipo, sem contrato direcional compativel | Sim, quando a fluent chain usa `Convert...Using()` diretamente | Sim, durante construcao do map e validacao da metadata efetiva | Error | `DFM014` no analyzer comum; `DFM012` no generator quando o materializer gerado consegue provar read converter invalido | +| duplicate read converter no mesmo property map | Sim, para fluent chain direta no construtor | Sim, pela fluent API/metadata | Error | `DFM015` | +| duplicate write converter no mesmo property map | Sim, para fluent chain direta no construtor | Sim, pela fluent API/metadata | Error | `DFM015` | +| inaccessible converter para generated materializer | Parcial. O generator emite fallback quando nao pode referenciar o converter de forma segura | Nao e erro runtime; runtime fallback permanece suportado | Info | `DFM011` quando o map usa fallback generated; sem erro porque o runtime suporta instancia/delegate/inacessivel | +| invalid generic arguments em `AddMap()` | Sim | Sim, quando a configuracao registra o map | Error | `DFM005` | +| invalid generic arguments em `AddProfile()` | Sim | Sim, quando a configuracao registra o profile | Error | `DFM009` | +| contradictory inherited configuration | Parcial. `IncludeBase()` invalido e comprovavel e `DFM004`; conflitos efetivos dependem de registro/ordem | Sim, na composicao efetiva de base/derivado/profile | Error | `DFM004` quando estatico; `FluentMapConfigurationException` em runtime | +| invalid profile configuration ou profile duplicado | Sim para `AddProfile()` invalido/duplicado no mesmo metodo de configuracao | Sim, no registro do profile | Error | `DFM009`, `DFM010` | +| impossible null conversion | Nao nesta etapa. Null/`DBNull` nao sao enviados ao converter por contrato, e NRT nao e contrato runtime em `netstandard2.0` | Parcial. Converter que retorna `null` para target value type nao nullable falha na materializacao com contexto | Error | `FluentMapConfigurationException` na materializacao; sem ID estatico confiavel | +| write converter em propriedade ignorada | Parcial, quando a chain direta contem `Ignore()` e write converter | Sim, porque a propriedade nunca participa de persistencia | Error | `DFM013` quando provado como comportamento de persistencia; `FluentMapConfigurationException` em runtime | +| write converter em propriedade read-only/computed nao chave e nunca persistida | Nao confiavel quando persistence metadata vem de map externo/base/profile | Sim, quando a metadata efetiva nao participa de insert, update nem key persistence | Error | `FluentMapConfigurationException` em runtime | +| write converter em Dommel `Insert`/`Update` esperando execucao property-scoped | Nao | Nao e erro por si; nesta etapa e metadata-only e a execucao Dommel preserva valores originais | Info/limite documentado | Sem diagnostic automatico; documentado em `07-write-conversion.md` | + +## Analyzer IDs + +Analyzer comum (`src/Dapper.FluentMap.Analyzers`): + +- `DFM001`: expressao `Map(...)` invalida. +- `DFM002`: member path duplicado no construtor do map. +- `DFM003`: coluna duplicada no construtor do map. +- `DFM004`: `IncludeBase()` nao aponta para base class valida. +- `DFM005`: `AddMap()` generico invalido. +- `DFM009`: `AddProfile()` generico invalido. +- `DFM010`: profile duplicado no mesmo metodo de configuracao. +- `DFM013`: comportamento de persistencia contraditorio. +- `DFM014`: property converter por tipo invalido. +- `DFM015`: property converter direcional duplicado na mesma fluent chain. + +Source generator (`src/Dapper.FluentMap.Generators`): + +- `DFM011`: fallback runtime para materializer gerado. +- `DFM012`: read converter gerado invalido. + +`DFM012` fica reservado ao generator para evitar duas regras diferentes com o +mesmo ID quando analyzer comum e generator estiverem instalados juntos. + +## Runtime validation + +`FluentMapper.Validate()` e o registro de maps validam a metadata efetiva depois +de aplicar explicit maps, `IncludeBase()`, profiles e conventions. + +Validacao adicionada: + +- conversion metadata nula em `IPropertyMapWithConversionMetadata` externo; +- descriptor de converter nulo ou com direcao inconsistente; +- read converter em propriedade ignorada; +- write converter em propriedade ignorada; +- write converter em propriedade que nao participa de insert, update nem key + persistence. + +As mensagens runtime seguem o padrao existente: +`FluentMapConfigurationException` com entity, member path, origem do map e uma +razao curta. O texto e diagnostico, nao contrato publico de mensagem exata. + +## Explain + +`Explain()` e `Explain()` ja expõem o converter +efetivo de forma estruturada em `MemberMappingExplanation.Conversion`. + +Exemplo de leitura estavel: + +```csharp +var status = FluentMapper.Explain() + .Members.Single(member => member.MemberPath == nameof(Customer.Status)); + +var converterType = status.Conversion.ReadConverter.ConverterType; +var databaseType = status.Conversion.ReadConverter.DatabaseType; +var source = status.Source; +``` + +Nao foi alterado `MappingExplanation.ToString()` neste incremento. O formato de +texto e util para orientacao rapida, mas a superficie estavel para diagnostics e +ferramentas e a API estruturada. + +## Extensibility review + +Consumers conseguem criar converters proprios sem depender de internals: + +- `IReadPropertyConverter`; +- `IWritePropertyConverter`; +- `IPropertyConverter`; +- overloads por tipo, instancia e delegate na fluent API; +- metadata publica read-only em `PropertyConversionMetadata` e + `PropertyConverterMetadata`. + +Nao foi exposta a instancia interna do converter nem detalhes de materializacao. +Isso preserva a fronteira publica: consumers implementam contratos, configuram +maps e inspecionam metadata; o runtime decide como executar. + +## Concurrency + +Converters continuam com contrato de reuso: + +- converter por tipo: uma instancia por property map runtime; no generated path, + campo estatico por binding gerado; +- converter por instancia/delegate: a instancia/delegate fornecida pelo usuario + e reutilizada; +- nao ha escopo por query nesta etapa. + +Documentacao XML agora declara que implementacoes devem ser stateless ou +thread-safe. Testes de hardening cobrem concorrencia em runtime materializer, +generated materializer e profiles. + +## Regression hardening + +Categorias cobertas ou reforcadas: + +- mesmo tipo de propriedade com converters diferentes; +- mesma entidade com converters diferentes em default map/profile; +- nested properties com mesmo terminal member name; +- converter + nullable; +- converter + constructor; +- converter + `TypeHandler`; +- equivalencia runtime/generated; +- assimetria read/write; +- Dommel mantendo write converter como metadata-only. + +## Performance interpretation + +Benchmarks da etapa 10 continuam representativos, nao estatisticos formais. O +custo esperado de converter e: + +- fixo: criacao/validacao de metadata, plano runtime ou campo estatico gerado; +- por linha: chamada de delegate/interface e conversao do valor bruto para o + `TDatabase` declarado quando necessario. + +Nenhuma otimizacao foi feita antes de medida. O objetivo de 10.6 e garantir que +o custo permanece visivel e que regressao funcional/concurrency seja detectada. diff --git a/.sdd/etapa-10/DECISIONS.md b/.sdd/etapa-10/DECISIONS.md index 230e466..21ae5bd 100644 --- a/.sdd/etapa-10/DECISIONS.md +++ b/.sdd/etapa-10/DECISIONS.md @@ -425,3 +425,42 @@ tinham antes. O suporte futuro depende de um hook publico de parametros por propriedade no Dommel ou de uma API explicita no pacote de integracao que deixe claro que usa parametros convertidos e nao os metodos Dommel existentes diretamente. + +## ADR-15 - Prompt 10.6 conversion diagnostics hardening + +### Contexto + +Converters agora possuem metadata, execucao runtime, suporte generated parcial +e uma fronteira Dommel/write documentada. Faltava consolidar diagnostics sem +transformar analyzers em executor de configuracao nem criar nova feature de +conversao. + +### Decisao + +O analyzer comum reporta somente configuracoes estaticamente provaveis: + +- `DFM014` para converter por tipo sem contrato read/write compativel; +- `DFM015` para duplicidade direcional na mesma fluent chain. + +O diagnostic de persistencia do analyzer comum foi renumerado para `DFM013`, +mantendo `DFM012` como diagnostic do generator para read converter gerado +invalido. + +`FluentMapper.Validate()` e o registro runtime continuam responsaveis pela +composicao efetiva: maps externos, base maps, profiles, conventions, +persistencia efetiva e converter em propriedade que nunca sera materializada ou +persistida. + +### Consequencias + +Nao ha execucao de construtores pelo analyzer. Consumers recebem feedback cedo +quando o codigo fonte permite prova estatica, e ainda precisam de +`FluentMapper.Validate()`/testes para configuracoes dinamicas. + +`Explain()` permanece com metadata estruturada em +`MemberMappingExplanation.Conversion`; o `ToString()` nao virou formato +diagnostico de contrato. + +Converters sao explicitamente documentados como stateless/thread-safe por +contrato, pois instancias podem ser reutilizadas em consultas concorrentes e no +caminho generated. diff --git a/.sdd/etapa-10/STATUS.md b/.sdd/etapa-10/STATUS.md index cfff601..314f40f 100644 --- a/.sdd/etapa-10/STATUS.md +++ b/.sdd/etapa-10/STATUS.md @@ -92,6 +92,24 @@ tipo e abrindo espaco para conversao por propriedade, map e profile. - Documentada a decisao de nao executar write converters em Dommel nesta etapa, preservando persistence semantics da Etapa 8 e comportamento atual de `Insert`/`Update`. +- Criado `.sdd/etapa-10/08-conversion-diagnostics.md`. +- Consolidada a matriz de diagnostics para converter configuration, + runtime validation, analyzer comum, generator e Dommel/write boundary. +- Renumerado o diagnostic de persistencia do analyzer comum para `DFM013`, + evitando colisao com `DFM012` do generator. +- Adicionados diagnostics do analyzer comum: + `DFM014` para property converter por tipo invalido e `DFM015` para converter + direcional duplicado na mesma fluent chain. +- `MappingConfigurationValidator` passou a validar conversion metadata efetiva, + incluindo metadata nula em property map externo, direcao inconsistente, + converter em propriedade ignorada e write converter em propriedade que nunca + participa de insert, update ou key persistence. +- Documentado no XML docs dos contratos publicos que converters podem ser + reutilizados por operacoes concorrentes e devem ser stateless/thread-safe. +- Reforcados testes de regressao para converters por propriedade com mesmo tipo, + read/write asymmetry, profiles concorrentes, generated concurrent conversion, + Dommel mantendo write conversion metadata-only e validacao runtime de + metadata externa invalida. ## Em andamento @@ -103,7 +121,8 @@ integracao. 1. Definir hook publico de parametros por propriedade antes de implementar write conversion/Dommel. -2. Evoluir diagnostics/analyzers alem do generator para reconhecer `Convert...`. +2. Avaliar diagnostics futuros somente quando houver nova superficie de write + conversion ou null conversion opt-in. 3. Aumentar benchmark formal quando houver decisao de otimizacao. ## Decisoes relevantes @@ -119,6 +138,10 @@ integracao. puder ser emitido com seguranca. - Dommel/write conversion e incremento separado porque a integracao atual nao transforma valores de parametros por propriedade. +- Prompt 10.6 consolida diagnostics e hardening sem mudar a semantica de + execucao: analyzer comum cobre somente conversores estaticamente provaveis, + runtime validation cobre composicao efetiva e Dommel continua metadata-only + para write converters. - Converters sao stateless/thread-safe por contrato e reutilizados. - AOT exige caminho por instancia/delegate ou referencia estatica gerada; nao deve depender de ativacao reflection-only. @@ -283,6 +306,36 @@ public interface IPropertyConverter : - `dotnet pack`: nao executado; este prompt alterou somente documentacao SDD e nao mudou empacotamento ou codigo produtivo. +## Validacao do Prompt 10.6 + +- `dotnet test .\test\Dapper.FluentMap.Analyzers.Tests\Dapper.FluentMap.Analyzers.Tests.csproj --configuration Release --filter FullyQualifiedName~FluentMapConfigurationAnalyzerTests`: + sucesso, 19 testes aprovados. +- `dotnet test .\test\Dapper.FluentMap.Tests\Dapper.FluentMap.Tests.csproj --configuration Release --filter "FullyQualifiedName~RuntimeReadConversionTests|FullyQualifiedName~ConfigurationValidationTests"`: + sucesso, 29 testes aprovados. Uma tentativa paralela anterior falhou por lock + temporario de build em `Dapper.FluentMap.dll`; rerun sequencial passou. +- `dotnet test .\test\Dapper.FluentMap.GeneratedRegistration.Tests\Dapper.FluentMap.GeneratedRegistration.Tests.csproj --configuration Release --filter FullyQualifiedName~GeneratedRegistrationIntegrationTests`: + sucesso, 5 testes aprovados. +- `dotnet test .\test\Dapper.FluentMap.Dommel.Tests\Dapper.FluentMap.Dommel.Tests.csproj --configuration Release --filter FullyQualifiedName~DommelPersistenceIntegrationTests`: + sucesso, 5 testes aprovados. +- `dotnet restore .\Dapper.FluentMap.sln`: sucesso. +- `dotnet build .\Dapper.FluentMap.sln --configuration Release --no-restore`: + sucesso, 0 warnings, 0 errors. +- `dotnet test .\Dapper.FluentMap.sln --configuration Release --no-build`: + sucesso, 402 testes aprovados no total. +- `dotnet run --configuration Release --project .\benchmarks\Dapper.FluentMap.Benchmarks\Dapper.FluentMap.Benchmarks.csproj -- --filter "*MaterializationSteadyStateBenchmarks.QueryMapped*Converter*" --job Dry --warmupCount 1 --minIterationCount 1 --maxIterationCount 2`: + sucesso. Resultado observado: runtime property converter 1.095 ms / + 165.98 KB, runtime simple converter 1.190 ms / 189.43 KB, generated property + converter 1.203 ms / 166.55 KB, generated simple converter 1.295 ms / + 189.99 KB, runtime no converter 1.805 ms / 142.55 KB. BenchmarkDotNet + alertou que os tempos de iteracao ficaram abaixo de 100 ms; usar como smoke + representativo, nao conclusao estatistica. +- `dotnet pack .\src\Dapper.FluentMap\Dapper.FluentMap.csproj --configuration Release --no-build --output .\artifacts\packages`: + sucesso, pacote criado em `artifacts/packages/Dapper.FluentMap.2.0.0.nupkg`; + warning conhecido `NU5125` sobre `licenseUrl` depreciado. +- `dotnet pack .\src\Dapper.FluentMap.Analyzers\Dapper.FluentMap.Analyzers.csproj --configuration Release --no-build --output .\artifacts\packages`: + sucesso, pacote criado em + `artifacts/packages/Dapper.FluentMap.Analyzers.2.0.0.nupkg`. + ## Interacao com Dapper TypeHandler Precedencia proposta para `QueryMapped*`: @@ -331,6 +384,7 @@ connection.Query() - `.sdd/etapa-10/05-performance-baseline.md` - `.sdd/etapa-10/06-generated-conversion.md` - `.sdd/etapa-10/07-write-conversion.md` +- `.sdd/etapa-10/08-conversion-diagnostics.md` - `.sdd/etapa-10/DECISIONS.md` - `.sdd/etapa-10/STATUS.md` - `src/Dapper.FluentMap/Materialization/NestedMaterializationPlan.cs` @@ -356,4 +410,4 @@ connection.Query() ## Ultimo prompt executado -Ultimo prompt executado: 10.5 +Ultimo prompt executado: 10.6 diff --git a/src/Dapper.FluentMap.Analyzers/AnalyzerReleases.Unshipped.md b/src/Dapper.FluentMap.Analyzers/AnalyzerReleases.Unshipped.md index 40c22d3..11dc932 100644 --- a/src/Dapper.FluentMap.Analyzers/AnalyzerReleases.Unshipped.md +++ b/src/Dapper.FluentMap.Analyzers/AnalyzerReleases.Unshipped.md @@ -9,4 +9,6 @@ DFM004 | Dapper.FluentMap.Configuration | Error | Included mapping type must be 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. -DFM012 | Dapper.FluentMap.Configuration | Error | Persistence mapping behavior is invalid. +DFM013 | Dapper.FluentMap.Configuration | Error | Persistence mapping behavior is invalid. +DFM014 | Dapper.FluentMap.Configuration | Error | Property converter is invalid. +DFM015 | Dapper.FluentMap.Configuration | Error | Property converter is configured more than once. diff --git a/src/Dapper.FluentMap.Analyzers/FluentMapConfigurationAnalyzer.cs b/src/Dapper.FluentMap.Analyzers/FluentMapConfigurationAnalyzer.cs index 3a9eb16..6333543 100644 --- a/src/Dapper.FluentMap.Analyzers/FluentMapConfigurationAnalyzer.cs +++ b/src/Dapper.FluentMap.Analyzers/FluentMapConfigurationAnalyzer.cs @@ -20,7 +20,9 @@ public sealed class FluentMapConfigurationAnalyzer : DiagnosticAnalyzer public const string InvalidGenericMapRegistrationDiagnosticId = "DFM005"; public const string InvalidGenericProfileRegistrationDiagnosticId = "DFM009"; public const string DuplicateProfileRegistrationDiagnosticId = "DFM010"; - public const string InvalidPersistenceBehaviorDiagnosticId = "DFM012"; + public const string InvalidPersistenceBehaviorDiagnosticId = "DFM013"; + public const string InvalidPropertyConverterDiagnosticId = "DFM014"; + public const string DuplicatePropertyConverterDiagnosticId = "DFM015"; private const string Category = "Dapper.FluentMap.Configuration"; private const string MappingNamespace = "Dapper.FluentMap.Mapping"; @@ -102,6 +104,24 @@ public sealed class FluentMapConfigurationAnalyzer : DiagnosticAnalyzer isEnabledByDefault: true, description: "Persistence mapping calls such as Ignore, Computed, DatabaseDefaultOnInsert, key and identity must not be combined in contradictory ways."); + private static readonly DiagnosticDescriptor InvalidPropertyConverterRule = new DiagnosticDescriptor( + InvalidPropertyConverterDiagnosticId, + "Property converter is invalid", + "Property path '{0}' has invalid {1} converter '{2}': {3}", + Category, + DiagnosticSeverity.Error, + isEnabledByDefault: true, + description: "Type-based FluentMap property converters must implement a compatible read or write converter contract for the mapped property path."); + + private static readonly DiagnosticDescriptor DuplicatePropertyConverterRule = new DiagnosticDescriptor( + DuplicatePropertyConverterDiagnosticId, + "Property converter is configured more than once", + "Property path '{0}' configures more than one {1} converter in this fluent chain", + Category, + DiagnosticSeverity.Error, + isEnabledByDefault: true, + description: "A single FluentMap property mapping can have at most one read converter and at most one write converter."); + public override ImmutableArray SupportedDiagnostics => ImmutableArray.Create( InvalidMapExpressionRule, @@ -111,7 +131,9 @@ public sealed class FluentMapConfigurationAnalyzer : DiagnosticAnalyzer InvalidGenericMapRegistrationRule, InvalidGenericProfileRegistrationRule, DuplicateProfileRegistrationRule, - InvalidPersistenceBehaviorRule); + InvalidPersistenceBehaviorRule, + InvalidPropertyConverterRule, + DuplicatePropertyConverterRule); public override void Initialize(AnalysisContext context) { @@ -457,6 +479,7 @@ private static bool TryCreateDirectConstructorMapInvocation( var ignored = false; var columnLocation = mapInvocation.GetLocation(); var persistenceState = new PersistenceChainState(); + var conversionState = new ConversionChainState(); SyntaxNode current = mapInvocation; while (current.Parent is MemberAccessExpressionSyntax memberAccess && @@ -493,6 +516,30 @@ private static bool TryCreateDirectConstructorMapInvocation( reason)); } } + else if (TryGetConversionAction(chainedMethod, out var conversionAction)) + { + foreach (var direction in conversionAction.Directions) + { + if (!conversionState.TryApply(direction)) + { + context.ReportDiagnostic(Diagnostic.Create( + DuplicatePropertyConverterRule, + GetInvocationNameLocation(chainedInvocation), + memberPath.Display, + FormatDirection(direction))); + } + } + + if (IsTypeBasedConversionInvocation(chainedMethod)) + { + ValidateTypeBasedConverter( + context, + chainedInvocation, + chainedMethod, + memberPath, + conversionAction); + } + } current = chainedInvocation; } @@ -782,6 +829,159 @@ private static bool IsPersistenceMethod(IMethodSymbol method) return false; } + private static bool TryGetConversionAction(IMethodSymbol method, out ConversionAction action) + { + action = null; + + if (method == null || !IsPropertyMapMethod(method)) + { + return false; + } + + switch (method.Name) + { + case "ConvertFromDatabaseUsing": + action = ConversionAction.Read; + return true; + case "ConvertToDatabaseUsing": + action = ConversionAction.Write; + return true; + case "ConvertUsing": + action = ConversionAction.ReadWrite; + return true; + default: + return false; + } + } + + private static bool IsTypeBasedConversionInvocation(IMethodSymbol method) + { + return method.IsGenericMethod && + method.TypeArguments.Length == 2 && + method.Parameters.Length == 0; + } + + private static void ValidateTypeBasedConverter( + SyntaxNodeAnalysisContext context, + InvocationExpressionSyntax invocation, + IMethodSymbol method, + MemberPathInfo memberPath, + ConversionAction action) + { + var converterType = method.TypeArguments[0] as INamedTypeSymbol; + var databaseType = method.TypeArguments[1]; + if (converterType == null || databaseType == null) + { + return; + } + + foreach (var direction in action.Directions) + { + if (TryFindCompatibleConverterContract( + context.Compilation, + converterType, + databaseType, + memberPath.TerminalType, + direction, + out var reason)) + { + continue; + } + + context.ReportDiagnostic(Diagnostic.Create( + InvalidPropertyConverterRule, + GetInvocationNameLocation(invocation), + memberPath.Display, + FormatDirection(direction), + FormatSymbol(converterType), + reason)); + } + } + + private static bool TryFindCompatibleConverterContract( + Compilation compilation, + INamedTypeSymbol converterType, + ITypeSymbol databaseType, + ITypeSymbol mappedPropertyType, + PropertyConversionDirection direction, + out string reason) + { + reason = null; + var interfaceName = direction == PropertyConversionDirection.Read + ? "IReadPropertyConverter`2" + : "IWritePropertyConverter`2"; + + var databaseMatches = converterType.AllInterfaces + .Where(type => IsType(type.OriginalDefinition, MappingNamespace, interfaceName)) + .Where(type => + { + var converterDatabaseType = direction == PropertyConversionDirection.Read + ? type.TypeArguments[0] + : type.TypeArguments[1]; + + return IsSameOrNullableEquivalent(converterDatabaseType, databaseType); + }) + .ToList(); + + if (databaseMatches.Count == 0) + { + reason = direction == PropertyConversionDirection.Read + ? $"it does not implement IReadPropertyConverter<{FormatSymbol(databaseType)}, TProperty>" + : $"it does not implement IWritePropertyConverter"; + return false; + } + + var matches = databaseMatches + .Where(type => + { + var converterPropertyType = direction == PropertyConversionDirection.Read + ? type.TypeArguments[1] + : type.TypeArguments[0]; + + return direction == PropertyConversionDirection.Read + ? CanAssignValue(compilation, mappedPropertyType, converterPropertyType) + : CanAssignValue(compilation, converterPropertyType, mappedPropertyType); + }) + .ToList(); + + if (matches.Count == 0) + { + var converterPropertyType = databaseMatches[0].TypeArguments[ + direction == PropertyConversionDirection.Read ? 1 : 0]; + reason = direction == PropertyConversionDirection.Read + ? $"it returns '{FormatSymbol(converterPropertyType)}', which cannot be assigned to mapped property type '{FormatSymbol(mappedPropertyType)}'" + : $"it accepts '{FormatSymbol(converterPropertyType)}', which is not compatible with mapped property type '{FormatSymbol(mappedPropertyType)}'"; + return false; + } + + if (matches.Count > 1) + { + reason = $"it matches more than one compatible {interfaceName.Replace("`2", "<,>")} contract"; + return false; + } + + return true; + } + + private static bool IsPropertyMapMethod(IMethodSymbol method) + { + var containingType = method.ContainingType; + if (IsType(containingType, DommelMappingNamespace, "DommelPropertyMap")) + { + return true; + } + + for (var current = containingType; current != null; current = current.BaseType) + { + if (IsType(current.OriginalDefinition, MappingNamespace, "PropertyMapBase`1")) + { + return true; + } + } + + return false; + } + private static Location GetInvocationNameLocation(InvocationExpressionSyntax invocation) { var memberAccess = invocation.Expression as MemberAccessExpressionSyntax; @@ -824,6 +1024,37 @@ private static bool IsType(INamedTypeSymbol type, string namespaceName, string m type.ContainingNamespace.ToDisplayString() == namespaceName; } + private static bool CanAssignValue(Compilation compilation, ITypeSymbol targetType, ITypeSymbol valueType) + { + if (IsSameOrNullableEquivalent(targetType, valueType)) + { + return true; + } + + var conversion = compilation.ClassifyConversion(valueType, targetType); + return conversion.IsImplicit; + } + + private static bool IsSameOrNullableEquivalent(ITypeSymbol left, ITypeSymbol right) + { + return SymbolEqualityComparer.Default.Equals(left, right) || + SymbolEqualityComparer.Default.Equals(GetNullableUnderlyingType(left), right) || + SymbolEqualityComparer.Default.Equals(GetNullableUnderlyingType(right), left); + } + + private static ITypeSymbol GetNullableUnderlyingType(ITypeSymbol type) + { + var namedType = type as INamedTypeSymbol; + if (namedType == null || + namedType.OriginalDefinition.SpecialType != SpecialType.System_Nullable_T || + namedType.TypeArguments.Length != 1) + { + return null; + } + + return namedType.TypeArguments[0]; + } + private static bool TryGetEntityMapInterface(INamedTypeSymbol mapType, out INamedTypeSymbol entityType) { entityType = null; @@ -862,6 +1093,16 @@ private static string FormatSymbol(ISymbol symbol) return symbol.ToDisplayString(SymbolDisplayFormat.CSharpErrorMessageFormat); } + private static string FormatSymbol(ITypeSymbol symbol) + { + return symbol.ToDisplayString(SymbolDisplayFormat.CSharpErrorMessageFormat); + } + + private static string FormatDirection(PropertyConversionDirection direction) + { + return direction == PropertyConversionDirection.Read ? "read" : "write"; + } + private sealed class MapInvocation { internal MapInvocation( @@ -903,11 +1144,12 @@ internal MapInvocation( private sealed class MemberPathInfo { - private MemberPathInfo(string key, string display, string terminalName) + private MemberPathInfo(string key, string display, string terminalName, ITypeSymbol terminalType) { Key = key; Display = display; TerminalName = terminalName; + TerminalType = terminalType; } internal string Key { get; } @@ -916,6 +1158,8 @@ private MemberPathInfo(string key, string display, string terminalName) internal string TerminalName { get; } + internal ITypeSymbol TerminalType { get; } + internal static MemberPathInfo Create(IEnumerable properties) { var propertyList = properties.ToList(); @@ -923,7 +1167,59 @@ internal static MemberPathInfo Create(IEnumerable properties) ".", 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); + var terminal = propertyList[propertyList.Count - 1]; + return new MemberPathInfo(key, display, terminal.Name, terminal.Type); + } + } + + private enum PropertyConversionDirection + { + Read, + Write + } + + private sealed class ConversionAction + { + internal static readonly ConversionAction Read = + new ConversionAction(new[] { PropertyConversionDirection.Read }); + internal static readonly ConversionAction Write = + new ConversionAction(new[] { PropertyConversionDirection.Write }); + internal static readonly ConversionAction ReadWrite = + new ConversionAction(new[] { PropertyConversionDirection.Read, PropertyConversionDirection.Write }); + + private ConversionAction(IEnumerable directions) + { + Directions = directions.ToArray(); + } + + internal IReadOnlyList Directions { get; } + } + + private sealed class ConversionChainState + { + private bool _read; + private bool _write; + + internal bool TryApply(PropertyConversionDirection direction) + { + if (direction == PropertyConversionDirection.Read) + { + if (_read) + { + return false; + } + + _read = true; + return true; + } + + if (_write) + { + return false; + } + + _write = true; + return true; } } diff --git a/src/Dapper.FluentMap.Analyzers/README.md b/src/Dapper.FluentMap.Analyzers/README.md index 0acb6fa..f68f2c6 100644 --- a/src/Dapper.FluentMap.Analyzers/README.md +++ b/src/Dapper.FluentMap.Analyzers/README.md @@ -2,7 +2,7 @@ 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. +Install it alongside the core package when you want compile-time feedback for invalid map expressions, duplicate member paths, duplicate columns, invalid `IncludeBase()` usage, invalid generic map/profile registration, invalid type-based property converters and duplicate converter configuration in a fluent chain. ```bash dotnet add package Dapper.FluentMap.Analyzers diff --git a/src/Dapper.FluentMap/Mapping/PropertyConversionMetadata.cs b/src/Dapper.FluentMap/Mapping/PropertyConversionMetadata.cs index dc97f2a..7aea6bf 100644 --- a/src/Dapper.FluentMap/Mapping/PropertyConversionMetadata.cs +++ b/src/Dapper.FluentMap/Mapping/PropertyConversionMetadata.cs @@ -7,6 +7,9 @@ namespace Dapper.FluentMap.Mapping /// /// Converts database/provider values to property values for one mapped property. /// + /// + /// Converter instances may be reused by concurrent materialization operations. Implementations should be stateless or otherwise thread-safe. + /// /// The CLR type produced by the database provider. /// The mapped property CLR type. public interface IReadPropertyConverter @@ -22,6 +25,9 @@ public interface IReadPropertyConverter /// /// Converts property values to database/provider values for one mapped property. /// + /// + /// Converter instances may be reused by concurrent persistence operations when an integration supports write conversion. Implementations should be stateless or otherwise thread-safe. + /// /// The mapped property CLR type. /// The CLR type sent to the database provider. public interface IWritePropertyConverter @@ -37,6 +43,9 @@ public interface IWritePropertyConverter /// /// Converts values in both read and write directions for one mapped property. /// + /// + /// Converter instances may be reused concurrently. Implementations should be stateless or otherwise thread-safe. + /// /// The database/provider CLR type. /// The mapped property CLR type. public interface IPropertyConverter : diff --git a/src/Dapper.FluentMap/MappingConfigurationValidator.cs b/src/Dapper.FluentMap/MappingConfigurationValidator.cs index 8f9d8e5..8c60251 100644 --- a/src/Dapper.FluentMap/MappingConfigurationValidator.cs +++ b/src/Dapper.FluentMap/MappingConfigurationValidator.cs @@ -151,10 +151,134 @@ private static MapDescriptor CreateDescriptor(Type entityType, IPropertyMap map, } ValidatePersistenceMetadata(entityType, map, memberPath, sourceKind, sourceType); + ValidateConversionMetadata(entityType, map, memberPath, sourceKind, sourceType); return new MapDescriptor(map, memberPath); } + private static void ValidateConversionMetadata(Type entityType, IPropertyMap map, MemberPath memberPath, string sourceKind, Type sourceType) + { + var conversion = PropertyMapConversion.GetConversion(map); + if (conversion == null) + { + throw InvalidConversionMetadata( + entityType, + memberPath, + sourceKind, + sourceType, + "Conversion metadata cannot be null."); + } + + if (conversion.HasReadConverter) + { + ValidateConverterDescriptor( + entityType, + memberPath, + sourceKind, + sourceType, + conversion.ReadConverter, + PropertyConversionDirection.Read); + } + + if (conversion.HasWriteConverter) + { + ValidateConverterDescriptor( + entityType, + memberPath, + sourceKind, + sourceType, + conversion.WriteConverter, + PropertyConversionDirection.Write); + } + + var persistence = PropertyMapPersistence.GetPersistence(map); + if (persistence == null) + { + return; + } + + if (persistence.IgnoredByFluentMap && conversion.HasReadConverter) + { + throw InvalidConversionMetadata( + entityType, + memberPath, + sourceKind, + sourceType, + "Ignored properties are never materialized by FluentMap and cannot use read converters."); + } + + if (conversion.HasWriteConverter && + persistence.IgnoredByFluentMap) + { + throw InvalidConversionMetadata( + entityType, + memberPath, + sourceKind, + sourceType, + "Ignored properties are never persisted and cannot use write converters."); + } + + if (conversion.HasWriteConverter && + !persistence.ParticipatesInInsert && + !persistence.ParticipatesInUpdate && + !persistence.IsKey) + { + throw InvalidConversionMetadata( + entityType, + memberPath, + sourceKind, + sourceType, + "The write converter is configured for a property that never participates in insert, update or key persistence."); + } + } + + private static void ValidateConverterDescriptor( + Type entityType, + MemberPath memberPath, + string sourceKind, + Type sourceType, + PropertyConverterMetadata converter, + PropertyConversionDirection expectedDirection) + { + if (converter == null) + { + throw InvalidConversionMetadata( + entityType, + memberPath, + sourceKind, + sourceType, + $"The {expectedDirection.ToString().ToLowerInvariant()} converter descriptor cannot be null."); + } + + if (converter.Direction != expectedDirection) + { + throw InvalidConversionMetadata( + entityType, + memberPath, + sourceKind, + sourceType, + $"The converter descriptor direction is '{converter.Direction}' but '{expectedDirection}' was expected."); + } + + if (converter.ConverterType == null || + converter.DatabaseType == null || + converter.PropertyType == null) + { + throw InvalidConversionMetadata( + entityType, + memberPath, + sourceKind, + sourceType, + "Converter type, database type and property type must all be present."); + } + } + + private static FluentMapConfigurationException InvalidConversionMetadata(Type entityType, MemberPath memberPath, string sourceKind, Type sourceType, string reason) + { + return new FluentMapConfigurationException( + $"Property path '{memberPath}' on entity '{FormatType(entityType)}' has invalid conversion metadata in {sourceKind} '{FormatType(sourceType)}'. {reason}"); + } + private static void ValidatePersistenceMetadata(Type entityType, IPropertyMap map, MemberPath memberPath, string sourceKind, Type sourceType) { var persistence = PropertyMapPersistence.GetPersistence(map); diff --git a/test/Dapper.FluentMap.Analyzers.Tests/FluentMapConfigurationAnalyzerTests.cs b/test/Dapper.FluentMap.Analyzers.Tests/FluentMapConfigurationAnalyzerTests.cs index 58b819e..de7cf8d 100644 --- a/test/Dapper.FluentMap.Analyzers.Tests/FluentMapConfigurationAnalyzerTests.cs +++ b/test/Dapper.FluentMap.Analyzers.Tests/FluentMapConfigurationAnalyzerTests.cs @@ -229,7 +229,7 @@ public void Configure(FluentMapConfiguration configuration) } [Fact] - public async Task PersistenceConfigurationAfterIgnoreShouldReportDfm012() + public async Task PersistenceConfigurationAfterIgnoreShouldReportDfm013() { var source = @" using Dapper.FluentMap.Mapping; @@ -255,7 +255,7 @@ public CustomerMap() } [Fact] - public async Task ComputedAndDatabaseDefaultShouldReportDfm012() + public async Task ComputedAndDatabaseDefaultShouldReportDfm013() { var source = @" using Dapper.FluentMap.Mapping; @@ -280,7 +280,7 @@ public CustomerMap() } [Fact] - public async Task DatabaseDefaultAndComputedShouldReportDfm012() + public async Task DatabaseDefaultAndComputedShouldReportDfm013() { var source = @" using Dapper.FluentMap.Mapping; @@ -305,7 +305,7 @@ public CustomerMap() } [Fact] - public async Task ComputedAndKeyShouldReportDfm012() + public async Task ComputedAndKeyShouldReportDfm013() { var source = @" using Dapper.FluentMap.Dommel.Mapping; @@ -330,7 +330,7 @@ public CustomerMap() } [Fact] - public async Task GeneratedOptionComputedAndDatabaseDefaultShouldReportDfm012() + public async Task GeneratedOptionComputedAndDatabaseDefaultShouldReportDfm013() { var source = @" using System.ComponentModel.DataAnnotations.Schema; @@ -358,7 +358,7 @@ public CustomerMap() } [Fact] - public async Task ValidPersistenceCombinationsShouldNotReportDfm012() + public async Task ValidPersistenceCombinationsShouldNotReportDfm013() { var source = @" using System.ComponentModel.DataAnnotations.Schema; @@ -400,6 +400,151 @@ public CustomerMap() Assert.DoesNotContain(diagnostics, diagnostic => diagnostic.Id == FluentMapConfigurationAnalyzer.InvalidPersistenceBehaviorDiagnosticId); } + [Fact] + public async Task InvalidReadConverterContractShouldReportDfm014() + { + var source = @" +using Dapper.FluentMap.Mapping; + +public enum AccountStatus +{ + Active +} + +public sealed class Customer +{ + public AccountStatus Status { get; set; } +} + +public sealed class StatusConverter +{ +} + +public sealed class CustomerMap : EntityMap +{ + public CustomerMap() + { + Map(c => c.Status).ConvertFromDatabaseUsing(); + } +}"; + + var diagnostic = await GetSingleDiagnosticAsync(source, FluentMapConfigurationAnalyzer.InvalidPropertyConverterDiagnosticId); + + AssertDiagnostic(diagnostic, DiagnosticSeverity.Error, "Property path 'Status' has invalid read converter 'StatusConverter'"); + AssertDiagnostic(diagnostic, DiagnosticSeverity.Error, "does not implement IReadPropertyConverter"); + AssertDiagnosticLineContains(source, diagnostic, "ConvertFromDatabaseUsing()"); + } + + [Fact] + public async Task InvalidWriteConverterContractShouldReportDfm014() + { + var source = @" +using Dapper.FluentMap.Mapping; + +public enum AccountStatus +{ + Active +} + +public sealed class Customer +{ + public AccountStatus Status { get; set; } +} + +public sealed class StatusConverter : IWritePropertyConverter +{ + public string ConvertToDatabase(int value) => value.ToString(); +} + +public sealed class CustomerMap : EntityMap +{ + public CustomerMap() + { + Map(c => c.Status).ConvertToDatabaseUsing(); + } +}"; + + var diagnostic = await GetSingleDiagnosticAsync(source, FluentMapConfigurationAnalyzer.InvalidPropertyConverterDiagnosticId); + + AssertDiagnostic(diagnostic, DiagnosticSeverity.Error, "Property path 'Status' has invalid write converter 'StatusConverter'"); + AssertDiagnostic(diagnostic, DiagnosticSeverity.Error, "accepts 'int', which is not compatible with mapped property type 'AccountStatus'"); + AssertDiagnosticLineContains(source, diagnostic, "ConvertToDatabaseUsing()"); + } + + [Fact] + public async Task DuplicateReadConverterInSameFluentChainShouldReportDfm015() + { + var source = @" +using Dapper.FluentMap.Mapping; + +public sealed class Customer +{ + public string Name { get; set; } +} + +public sealed class FirstConverter : IReadPropertyConverter +{ + public string ConvertFromDatabase(string value) => value; +} + +public sealed class SecondConverter : IReadPropertyConverter +{ + public string ConvertFromDatabase(string value) => value; +} + +public sealed class CustomerMap : EntityMap +{ + public CustomerMap() + { + Map(c => c.Name) + .ConvertFromDatabaseUsing() + .ConvertFromDatabaseUsing(); + } +}"; + + var diagnostic = await GetSingleDiagnosticAsync(source, FluentMapConfigurationAnalyzer.DuplicatePropertyConverterDiagnosticId); + + AssertDiagnostic(diagnostic, DiagnosticSeverity.Error, "Property path 'Name' configures more than one read converter"); + AssertDiagnosticLineContains(source, diagnostic, "ConvertFromDatabaseUsing()"); + } + + [Fact] + public async Task DuplicateWriteConverterInSameFluentChainShouldReportDfm015() + { + var source = @" +using Dapper.FluentMap.Mapping; + +public sealed class Customer +{ + public string Name { get; set; } +} + +public sealed class FirstConverter : IWritePropertyConverter +{ + public string ConvertToDatabase(string value) => value; +} + +public sealed class SecondConverter : IWritePropertyConverter +{ + public string ConvertToDatabase(string value) => value; +} + +public sealed class CustomerMap : EntityMap +{ + public CustomerMap() + { + Map(c => c.Name) + .ConvertToDatabaseUsing() + .ConvertToDatabaseUsing(); + } +}"; + + var diagnostic = await GetSingleDiagnosticAsync(source, FluentMapConfigurationAnalyzer.DuplicatePropertyConverterDiagnosticId); + + AssertDiagnostic(diagnostic, DiagnosticSeverity.Error, "Property path 'Name' configures more than one write converter"); + AssertDiagnosticLineContains(source, diagnostic, "ConvertToDatabaseUsing()"); + } + [Fact] public async Task ValidMappingConfigurationShouldNotReportDiagnostics() { diff --git a/test/Dapper.FluentMap.Dommel.Tests/DommelPersistenceIntegrationTests.cs b/test/Dapper.FluentMap.Dommel.Tests/DommelPersistenceIntegrationTests.cs index fee32f8..cf8e231 100644 --- a/test/Dapper.FluentMap.Dommel.Tests/DommelPersistenceIntegrationTests.cs +++ b/test/Dapper.FluentMap.Dommel.Tests/DommelPersistenceIntegrationTests.cs @@ -4,6 +4,7 @@ using System.Linq; using Dapper; using Dapper.FluentMap.Dommel.Mapping; +using Dapper.FluentMap.Mapping; using Dommel; using Microsoft.Data.Sqlite; using Xunit; @@ -289,6 +290,51 @@ PRIMARY KEY (key_part_one, key_part_two) } } + [Fact] + public void InsertAndUpdateShouldNotExecuteWriteConvertersWithoutDommelParameterHook() + { + PreTest(); + SQLitePCL.Batteries_V2.Init(); + + FluentMapper.Initialize(config => + { + config.AddMap(new WriteConversionBoundaryEntityMap()); + config.ForDommel(); + }); + + using (var connection = OpenConnection()) + { + connection.Execute(@" +CREATE TABLE write_conversion_boundary_entities ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + code TEXT NOT NULL +);"); + + var entity = new WriteConversionBoundaryEntity + { + Code = "original-insert" + }; + + var id = Convert.ToInt32(connection.Insert(entity)); + var inserted = connection.QuerySingle( + "SELECT code FROM write_conversion_boundary_entities WHERE id = @id;", + new { id }); + + Assert.Equal("original-insert", inserted); + + entity.Id = id; + entity.Code = "original-update"; + + Assert.True(connection.Update(entity)); + + var updated = connection.QuerySingle( + "SELECT code FROM write_conversion_boundary_entities WHERE id = @id;", + new { id }); + + Assert.Equal("original-update", updated); + } + } + private static void PreTest() { FluentMapper.EntityMaps.Clear(); @@ -467,5 +513,32 @@ public CompositePersistenceEntityMap() Map(entity => entity.Value).ToColumn("value"); } } + + private sealed class WriteConversionBoundaryEntity + { + public int Id { get; set; } + + public string Code { get; set; } + } + + private sealed class WriteConversionBoundaryEntityMap : DommelEntityMap + { + public WriteConversionBoundaryEntityMap() + { + ToTable("write_conversion_boundary_entities"); + Map(entity => entity.Id).ToColumn("id").IsIdentity(); + Map(entity => entity.Code) + .ToColumn("code") + .ConvertToDatabaseUsing(); + } + } + + private sealed class ThrowingWriteConverter : IWritePropertyConverter + { + public string ConvertToDatabase(string value) + { + throw new InvalidOperationException("Dommel does not execute property write converters in this stage."); + } + } } } diff --git a/test/Dapper.FluentMap.GeneratedRegistration.Tests/GeneratedRegistrationIntegrationTests.cs b/test/Dapper.FluentMap.GeneratedRegistration.Tests/GeneratedRegistrationIntegrationTests.cs index 60a6486..0e89e7a 100644 --- a/test/Dapper.FluentMap.GeneratedRegistration.Tests/GeneratedRegistrationIntegrationTests.cs +++ b/test/Dapper.FluentMap.GeneratedRegistration.Tests/GeneratedRegistrationIntegrationTests.cs @@ -1,4 +1,5 @@ using System; +using System.Linq; using Dapper; using Dapper.FluentMap.Mapping; using Dapper.FluentMap.Naming; @@ -315,6 +316,45 @@ public void GeneratedQueryMappedShouldWrapReadConverterExceptions() } } + [Fact] + [Trait("Category", "Integration")] + public void GeneratedQueryMappedShouldApplyReadConvertersAcrossConcurrentDefaultAndProfileQueries() + { + ResetMapper(); + + try + { + FluentMapper.Initialize(configuration => configuration.AddGeneratedMappings()); + + var results = System.Linq.Enumerable.Range(0, 32) + .AsParallel() + .Select(index => + { + using (var connection = OpenConnection()) + { + if (index % 2 == 0) + { + var current = connection.QueryMappedSingle( + "SELECT 'A' AS status;"); + return current.Status == GeneratedAccountStatus.Active; + } + + var legacy = connection.QueryMappedSingle( + "SELECT '1' AS legacy_status;"); + return legacy.Status == GeneratedAccountStatus.Inactive; + } + }) + .ToList(); + + Assert.All(results, Assert.True); + Assert.Equal(0, FluentMapper.Registry.MaterializationPlanCacheEntryCount); + } + finally + { + ResetMapper(); + } + } + private static SqliteConnection OpenConnection() { var connection = new SqliteConnection("Data Source=:memory:"); diff --git a/test/Dapper.FluentMap.Tests/ConfigurationValidationTests.cs b/test/Dapper.FluentMap.Tests/ConfigurationValidationTests.cs index d26ba21..9dacc62 100644 --- a/test/Dapper.FluentMap.Tests/ConfigurationValidationTests.cs +++ b/test/Dapper.FluentMap.Tests/ConfigurationValidationTests.cs @@ -170,6 +170,32 @@ public void InvalidPersistenceMetadataShouldThrowUsefulConfigurationException() Assert.Contains("Ignored flag and persistence metadata disagree", exception.Message); } + [Fact] + public void InvalidConversionMetadataShouldThrowUsefulConfigurationException() + { + PreTest(typeof(ConversionValidationEntity)); + + var exception = Assert.Throws(() => + FluentMapper.Initialize(c => c.AddMap(new InvalidConversionValidationMap()))); + + Assert.Contains(nameof(ConversionValidationEntity.Status), exception.Message); + Assert.Contains("invalid conversion metadata", exception.Message); + Assert.Contains("Conversion metadata cannot be null", exception.Message); + } + + [Fact] + public void WriteConverterForNeverPersistedPropertyShouldThrowUsefulConfigurationException() + { + PreTest(typeof(ConversionValidationEntity)); + + var exception = Assert.Throws(() => + FluentMapper.Initialize(c => c.AddMap(new ReadOnlyWriteConversionValidationMap()))); + + Assert.Contains(nameof(ConversionValidationEntity.Status), exception.Message); + Assert.Contains("write converter", exception.Message); + Assert.Contains("never participates", exception.Message); + } + [Fact] public void ConventionWithoutConfigureShouldThrowConfigurationException() { @@ -343,6 +369,61 @@ public InvalidPersistencePropertyMap(PropertyInfo propertyInfo) public PropertyPersistenceMetadata Persistence => PropertyPersistenceMetadata.Default; } + private class ConversionValidationEntity + { + public string Status { get; set; } + } + + private class ReadOnlyWriteConversionValidationMap : EntityMap + { + public ReadOnlyWriteConversionValidationMap() + { + Map(e => e.Status) + .ConvertToDatabaseUsing() + .ReadOnly(); + } + } + + private class InvalidConversionValidationMap : IEntityMap + { + public InvalidConversionValidationMap() + { + PropertyMaps = new List + { + new InvalidConversionPropertyMap( + typeof(ConversionValidationEntity).GetProperty(nameof(ConversionValidationEntity.Status))) + }; + } + + public IList PropertyMaps { get; } + } + + private class InvalidConversionPropertyMap : IPropertyMap, IPropertyMapWithConversionMetadata + { + public InvalidConversionPropertyMap(PropertyInfo propertyInfo) + { + PropertyInfo = propertyInfo; + } + + public string ColumnName => PropertyInfo.Name; + + public PropertyInfo PropertyInfo { get; } + + public bool CaseSensitive => true; + + public bool Ignored => false; + + public PropertyConversionMetadata Conversion => null; + } + + private sealed class StatusWriteConverter : IWritePropertyConverter + { + public string ConvertToDatabase(string value) + { + return value; + } + } + private class NestedLevelEntity { public RankInfo Rank { get; set; } diff --git a/test/Dapper.FluentMap.Tests/RuntimeReadConversionTests.cs b/test/Dapper.FluentMap.Tests/RuntimeReadConversionTests.cs index 133eac1..98b248c 100644 --- a/test/Dapper.FluentMap.Tests/RuntimeReadConversionTests.cs +++ b/test/Dapper.FluentMap.Tests/RuntimeReadConversionTests.cs @@ -2,6 +2,7 @@ using System.Collections.Generic; using System.Data; using System.Linq; +using System.Threading; using System.Threading.Tasks; using Dapper; using Dapper.FluentMap.Mapping; @@ -187,6 +188,99 @@ public void QueryMappedShouldRespectProfileScopedReadConverters() } } + [Fact] + [Trait("Category", "Integration")] + public void QueryMappedShouldKeepConvertersScopedToPropertyWhenPropertyTypesMatch() + { + PreTest(typeof(MultipleStatusConversionCustomer)); + + try + { + FluentMapper.Initialize(configuration => configuration.AddMap(new MultipleStatusConversionCustomerMap())); + + using (var connection = OpenConnection()) + { + var customer = connection.QueryMappedSingle( + "SELECT 'same' AS primary_status, 'same' AS secondary_status;"); + + Assert.Equal(AccountStatus.Active, customer.PrimaryStatus); + Assert.Equal(AccountStatus.Inactive, customer.SecondaryStatus); + } + } + finally + { + PreTest(typeof(MultipleStatusConversionCustomer)); + } + } + + [Fact] + [Trait("Category", "Integration")] + public void QueryMappedShouldIgnoreWriteOnlyConverterDuringReadMaterialization() + { + PreTest(typeof(WriteOnlyReadConversionCustomer)); + + try + { + FluentMapper.Initialize(configuration => configuration.AddMap(new WriteOnlyReadConversionCustomerMap())); + + using (var connection = OpenConnection()) + { + var customer = connection.QueryMappedSingle( + "SELECT 'Active' AS status;"); + + Assert.Equal(AccountStatus.Active, customer.Status); + } + } + finally + { + PreTest(typeof(WriteOnlyReadConversionCustomer)); + } + } + + [Fact] + [Trait("Category", "Integration")] + public void QueryMappedShouldApplyReadConvertersAcrossConcurrentDefaultAndProfileQueries() + { + PreTest(typeof(ProfileConversionCustomer)); + CountingStatusConverter.Calls = 0; + + try + { + FluentMapper.Initialize(configuration => + { + configuration.AddMap(new DefaultProfileConversionCustomerMap()); + configuration.AddProfile(); + }); + + var results = Enumerable.Range(0, 32) + .AsParallel() + .Select(index => + { + using (var connection = OpenConnection()) + { + if (index % 2 == 0) + { + var current = connection.QueryMappedSingle( + "SELECT 'A' AS status;"); + return current.Status == AccountStatus.Active; + } + + var legacy = connection.QueryMappedSingle( + "SELECT '1' AS legacy_status;"); + return legacy.Status == AccountStatus.Inactive; + } + }) + .ToList(); + + Assert.All(results, Assert.True); + Assert.Equal(16, CountingStatusConverter.Calls); + } + finally + { + PreTest(typeof(ProfileConversionCustomer)); + } + } + [Fact] public void ReadMappedShouldApplyReadConvertersFromCommonMaterializer() { @@ -401,11 +495,11 @@ public ConversionCustomerMap() private sealed class CountingStatusConverter : IReadPropertyConverter { - public static int Calls { get; set; } + public static int Calls; public AccountStatus ConvertFromDatabase(string value) { - Calls++; + Interlocked.Increment(ref Calls); return value == "A" ? AccountStatus.Active : AccountStatus.Unknown; } } @@ -536,6 +630,65 @@ public LegacyProfileConversionCustomerMap() } } + private sealed class MultipleStatusConversionCustomer + { + public AccountStatus PrimaryStatus { get; set; } + + public AccountStatus SecondaryStatus { get; set; } + } + + private sealed class MultipleStatusConversionCustomerMap : EntityMap + { + public MultipleStatusConversionCustomerMap() + { + Map(customer => customer.PrimaryStatus) + .ToColumn("primary_status") + .ConvertFromDatabaseUsing(); + Map(customer => customer.SecondaryStatus) + .ToColumn("secondary_status") + .ConvertFromDatabaseUsing(); + } + } + + private sealed class PrimaryStatusConverter : IReadPropertyConverter + { + public AccountStatus ConvertFromDatabase(string value) + { + return AccountStatus.Active; + } + } + + private sealed class SecondaryStatusConverter : IReadPropertyConverter + { + public AccountStatus ConvertFromDatabase(string value) + { + return AccountStatus.Inactive; + } + } + + private sealed class WriteOnlyReadConversionCustomer + { + public AccountStatus Status { get; set; } + } + + private sealed class WriteOnlyReadConversionCustomerMap : EntityMap + { + public WriteOnlyReadConversionCustomerMap() + { + Map(customer => customer.Status) + .ToColumn("status") + .ConvertToDatabaseUsing(); + } + } + + private sealed class ThrowingWriteConverter : IWritePropertyConverter + { + public string ConvertToDatabase(AccountStatus value) + { + throw new InvalidOperationException("Write converter should not run during read materialization."); + } + } + private sealed class HandledCode { public HandledCode(string value) From ee5509833be8a3e7786ff5ea882dd42096edd703 Mon Sep 17 00:00:00 2001 From: Rodrigo de Oliveira Date: Wed, 29 Jul 2026 07:07:24 -0300 Subject: [PATCH 28/49] docs(conversion): finalize property converter support --- .sdd/etapa-10/05-performance-baseline.md | 52 +++++ .sdd/etapa-10/FINAL-REPORT.md | 282 +++++++++++++++++++++++ .sdd/etapa-10/STATUS.md | 34 ++- README.md | 262 ++++++++++++++++++--- 4 files changed, 600 insertions(+), 30 deletions(-) create mode 100644 .sdd/etapa-10/FINAL-REPORT.md diff --git a/.sdd/etapa-10/05-performance-baseline.md b/.sdd/etapa-10/05-performance-baseline.md index b9f54df..4f89724 100644 --- a/.sdd/etapa-10/05-performance-baseline.md +++ b/.sdd/etapa-10/05-performance-baseline.md @@ -159,3 +159,55 @@ Interpretacao: - As alocacoes ficaram alinhadas com a baseline anterior: generated converter adiciona diferenca pequena de descriptor/caminho gerado, e runtime converter nao introduziu nova alocacao observavel por linha nesta medicao curta. + +## Resultados finais da Etapa 10 + +No Prompt 10.7 foram executados benchmarks representativos em 2026-07-29, com +o mesmo perfil curto usado nos prompts anteriores: + +```powershell +dotnet run --configuration Release --project .\benchmarks\Dapper.FluentMap.Benchmarks\Dapper.FluentMap.Benchmarks.csproj -- --filter "*MaterializationSteadyStateBenchmarks.QueryMapped*Converter*" --job Dry --warmupCount 1 --minIterationCount 1 --maxIterationCount 2 +``` + +Tambem foi executado um recorte separado para Dapper puro/default conversion: + +```powershell +dotnet run --configuration Release --project .\benchmarks\Dapper.FluentMap.Benchmarks\Dapper.FluentMap.Benchmarks.csproj -- --filter "*MaterializationSteadyStateBenchmarks.DapperPure" --job Dry --warmupCount 1 --minIterationCount 1 --maxIterationCount 2 +``` + +Ambiente reportado: + +```text +BenchmarkDotNet v0.15.8 +Windows 11 25H2 +.NET SDK 10.0.302 +.NET Runtime 10.0.10 +Intel Core i5-1145G7 +``` + +Resultados observados: + +| Scenario | Method | Mean | Allocated | Overhead vs comparable no-converter | +|---|---|---:|---:|---:| +| Dapper/default conversion | `DapperPure` | 2.071 ms | 283.22 KB | Not comparable: 5-column Dapper baseline | +| FluentMap sem converter | `QueryMappedRuntimeNoConverter` | 1.536 ms | 142.55 KB | Baseline for 2-column runtime converter shapes | +| FluentMap runtime converter | `QueryMappedRuntimeSimpleConverter` | 2.036 ms | 189.43 KB | +0.500 ms / +46.88 KB | +| FluentMap generated converter | `QueryMappedGeneratedSimpleConverter` | 2.206 ms | 189.99 KB | +0.670 ms / +47.44 KB | +| FluentMap runtime property converter | `QueryMappedRuntimePropertyConverter` | 1.390 ms | 165.98 KB | -0.146 ms / +23.43 KB | +| FluentMap generated property converter | `QueryMappedGeneratedPropertyConverter` | 1.421 ms | 166.55 KB | -0.115 ms / +24.00 KB | + +Interpretacao final: + +- Esta continua sendo uma execucao smoke, nao uma amostra estatistica formal. + BenchmarkDotNet alertou que todos os tempos de iteracao ficaram abaixo de + 100 ms. +- A comparacao Dapper/default conversion usa um shape de 5 colunas e serve como + referencia geral do ambiente, nao como par semantico dos benchmarks de + converter de 2 colunas. +- As alocacoes dos cenarios de converter permaneceram na mesma ordem das + medicoes anteriores. O custo adicional esperado aparece como chamada do + converter e, em alguns cenarios, conversao do valor bruto para `TDatabase`. +- O resultado de tempo local permanece ruidoso: nao ha evidencia suficiente + para afirmar vantagem de runtime ou generated converter em throughput. + A conclusao suportada e que a Etapa 10 nao introduziu regressao obvia de + alocacao ou falha funcional nos cenarios representativos. diff --git a/.sdd/etapa-10/FINAL-REPORT.md b/.sdd/etapa-10/FINAL-REPORT.md new file mode 100644 index 0000000..1e18cfa --- /dev/null +++ b/.sdd/etapa-10/FINAL-REPORT.md @@ -0,0 +1,282 @@ +# Etapa 10 — Final Report + +## Objetivo + +Encerrar a Etapa 10 - Property Conversion & Extensibility com auditoria da +especificacao, revisao de API publica, documentacao, validacao runtime/generated, +fronteira Dommel/write, performance e smoke trimming/AOT, sem iniciar recursos +da Etapa 11. + +## Implementado + +- Contratos publicos direcionais: + `IReadPropertyConverter`, + `IWritePropertyConverter` e + `IPropertyConverter`. +- Delegates publicos: + `ReadPropertyConverter` e + `WritePropertyConverter`. +- Fluent API por tipo, instancia e delegate: + `ConvertFromDatabaseUsing`, `ConvertToDatabaseUsing` e `ConvertUsing`. +- Metadata publica aditiva: + `PropertyConversionMetadata`, `PropertyConverterMetadata`, + `PropertyConversionDirection` e `IPropertyMapWithConversionMetadata`. +- Read conversion no runtime materializer comum de `QueryMapped*`, + `ReadMapped*`, `QueryMultipleMapped` e streaming unbuffered. +- Read conversion no generated materializer para converter types suportados + estaticamente. +- Diagnostics em runtime validation, analyzer comum e source generator. +- Documentacao publica atualizada no `README.md`. + +## Audit SDD + +| Requirement | Implementation | Tests | Generated | AOT | Status | +| ----------- | -------------- | ----- | --------- | --- | ------ | +| Contratos read/write independentes | Interfaces direcionais, bidirecional e delegates em `Dapper.FluentMap.Mapping` | `PropertyConversionMetadataTests` | Metadata incluida em descriptors read gerados | Overloads por tipo anotados; instancia/delegate evitam construcao por tipo | Completed | +| Property-scoped conversion | Metadata fica no `PropertyMap` efetivo/member path | Testes com duas propriedades do mesmo tipo | Descriptor valida member path e converter por coluna | Sem estado global novo por tipo | Completed | +| Profile-scoped conversion | Profiles usam maps separados e nao herdam converter default automaticamente | Runtime e generated profile converter tests | Descriptor separado por entity/profile/shape | Registro gerado evita scanning para maps da compilacao atual | Completed | +| Read precedence | `null/DBNull -> property converter -> TypeHandler -> default conversion` no runtime | `RuntimeReadConversionTests`, `DapperCompatibilityAdapterTests` | Generated aplica `null/DBNull -> property converter -> default generated conversion` | `QueryMapped*` permanece anotado por fallback runtime | Completed | +| Dapper `Query()` unchanged | Type map de nomes continua separado de converters property-scoped | Coberto por regressao existente e ausencia de mudanca nesse caminho | Not applicable | Not applicable | Completed | +| TypeHandler sem converter | Runtime consulta `TypeHandler` antes da conversao default | `QueryMappedShouldUseRegisteredDapperTypeHandler*` | TypeHandler no generated path segue fora da etapa | Fallback runtime documentado | Completed | +| Converter + TypeHandler | Converter local tem precedencia na propriedade configurada | `QueryMappedShouldUsePropertyConverterInsteadOfDapperTypeHandlerForThatProperty` | Generated converter nao encadeia TypeHandler | Sem reflexao de internals do Dapper no generated path | Completed | +| Null semantics | `null`/`DBNull` nao entram no converter; targets recebem null/default | Runtime e generated null converter tests | `ReadConverted` replica regra | Preserva semantica de subarvore null | Completed | +| Nullable | `T` e `Nullable` tratados como compativeis em configuracao e execucao | Metadata/runtime/generated nullable tests | Generated usa `TTarget` real | Completed | Completed | +| Nested converter | Converter aplica somente a folha terminal do member path | Nested runtime/generated tests | Generated respeita null subtree | Completed | Completed | +| Immutable/value-object converter | Folhas convertidas antes de construtores; Value Object escalar pode usar property converter | Runtime/generated immutable e Value Object tests | Supported para converter type estatico | Generated evita reflection no hot path | Completed | +| Runtime/generated equivalence | Teste dedicado compara resultados runtime e generated | `GeneratedQueryMappedShouldMatchRuntimeFallbackForReadConverters` | Sim | Parcialmente AOT-friendly quando generated e shape casam | Completed | +| Write conversion execution | Core guarda metadata; Dommel nao executa por falta de hook publico de parametro | Dommel boundary test garante que `Insert`/`Update` nao chamam converter | Write-only nao afeta materializer read | Not applicable | Deferred | +| Persistence semantics Etapa 8 | Metadata de persistence continua governando insert/update/select | Dommel persistence tests, 22 testes isolados | Write metadata neutra para read generated | Not applicable | Completed | +| Diagnostics/analyzers | Runtime validation, `DFM014`, `DFM015`, generator `DFM012`, fallback `DFM011` | Analyzer/generator/configuration tests | Diagnostics gerados sem executar construtores | Not applicable | Completed | +| Performance baseline | Benchmarks representativos atualizados | BenchmarkDotNet Dry | Runtime e generated converter medidos | Not applicable | Completed | +| Trimming/AOT | `QueryMapped*` anotado; generated converter evita reflexao por linha | Trim smoke publicado e executado | Generated smoke retorna `generated:ok` | Native AOT bloqueado por linker ausente | Partial | + +Divergencias justificadas: + +- Write conversion nao foi executada em Dommel porque a API publica Dommel 3.5.3 + nao expoe hook para substituir `DbParameter.Value` por propriedade antes do + Dapper parametrizar a entidade. O comportamento fica documentado como + metadata-only. +- TypeHandler no generated path continua adiado desde a Etapa 7. O generated + path nao usa internals do Dapper; cenarios que dependem de TypeHandler usam + runtime fallback. +- `QueryMapped*` nao e declarado Native AOT-safe porque pode cair no + materializer runtime baseado em reflection/dynamic code. + +## Converter Model + +O modelo final separa conversao por direcao e por escopo. Um converter pertence +ao member path efetivo do property map e, quando configurado em profile, ao +profile selecionado. `ConvertUsing` e atalho bidirecional para tipos que +implementam as duas interfaces compativeis. + +Revisao de API: + +- Naming esta consistente com as direcoes: `ConvertFromDatabaseUsing` para + leitura e `ConvertToDatabaseUsing` para escrita. +- `IPropertyConverter` e util como contrato bidirecional, + sem obrigar cenarios read-only/write-only. +- Overloads por tipo exigem `new()` e construtor publico parameterless; nao ha + DI/factory nesta etapa. +- A instancia interna do converter nao e exposta na metadata publica. +- Nao foram feitas mudancas de API no prompt 10.7; dividas maiores foram + documentadas. + +## Read Conversion + +Read conversion esta implementada para os caminhos controlados pelo FluentMap: +`QueryMapped*`, `ReadMapped*`, `QueryMultipleMapped`, unbuffered sincrono e +unbuffered assincrono. A precedencia real e: + +```text +null/DBNull + -> property read converter + -> Dapper TypeHandler + -> FluentMap default conversion +``` + +APIs Dapper puras (`Query()`, `QuerySingle()`) nao executam converters por +propriedade. + +## Write Conversion + +Write converter existe como contrato e metadata publica, mas nao e executado +por Dapper ou Dommel na Etapa 10. Dommel `Insert`/`Update` continuam passando a +entidade original ao Dapper, que aplica seu fluxo normal de parametrizacao. + +## Dapper TypeHandler Interoperability + +Valido explicitamente: + +- property converter sem TypeHandler: runtime e generated aplicam converter de + leitura nos cenarios suportados; +- TypeHandler sem converter: runtime `QueryMapped*` usa `TypeHandler`; +- converter + TypeHandler: property converter vence na propriedade configurada; +- converter por profile: profile selecionado usa seu converter proprio; +- nested converter: converter fica no member path terminal; +- immutable/value-object converter: conversao ocorre antes de construtor ou + produz o Value Object escalar inteiro. + +Regra conceitual documentada: + +```text +TypeHandler -> comportamento por tipo +Property Converter -> comportamento por mapping/member/profile +``` + +## Profiles + +Profiles permanecem query-scoped. Converters do map default nao vazam para +profiles. Reuso precisa ser explicito por configuracao do profile ou por +`IncludeBase()` quando aplicavel. + +## Nested and Value Objects + +Converters se aplicam a folhas terminais de caminhos aninhados. Subarvores +totalmente `DBNull` continuam nao sendo criadas e seus converters nao rodam. +Value Objects escalares podem usar property converter; Value Objects por +componentes continuam usando construtores publicos compativeis. + +## Runtime Materialization + +`NestedMaterializationPlan` anexa conversion metadata a cada folha e cria um +delegate por folha no plano. A criacao/validacao fica fora do custo por linha; +o hot path executa leitura do `IDataRecord`, tratamento de null e chamada de +converter/default conversion. + +## Generated Materialization + +O source generator emite read converters quando o converter e por tipo, +acessivel, parameterless e implementa contrato compativel. O descriptor carrega +converter type, database type e property type para validar match com o mapping +efetivo. Instancias, delegates e padroes nao suportados usam runtime fallback. + +## Diagnostics and Analyzers + +- Runtime validation cobre metadata efetiva, direcoes inconsistentes, + converter em propriedade ignorada e write converter em propriedade sem + participacao de persistencia. +- Analyzer comum cobre `DFM014` para converter por tipo invalido e `DFM015` + para converter direcional duplicado. +- Generator cobre `DFM012` para read converter gerado invalido e `DFM011` para + fallback informativo. +- `Explain()` expoe metadata estruturada em + `MemberMappingExplanation.Conversion`. + +## Dommel Integration + +Dommel preserva persistence metadata da Etapa 8 para `SELECT`, `INSERT`, +`UPDATE`, generated, computed, identity, read-only e keys. Write converters sao +metadata-only e nao alteram `Insert`, `Update`, `InsertAll` ou variantes async. + +## Performance + +Benchmarks finais em 2026-07-29: + +| Scenario | Mean | Allocated | +|---|---:|---:| +| Dapper/default conversion (`DapperPure`) | 2.071 ms | 283.22 KB | +| FluentMap sem converter (`QueryMappedRuntimeNoConverter`) | 1.536 ms | 142.55 KB | +| FluentMap runtime converter simples | 2.036 ms | 189.43 KB | +| FluentMap generated converter simples | 2.206 ms | 189.99 KB | +| FluentMap runtime property converter Value Object | 1.390 ms | 165.98 KB | +| FluentMap generated property converter Value Object | 1.421 ms | 166.55 KB | + +BenchmarkDotNet alertou que as iteracoes ficaram abaixo de 100 ms. Use os +resultados como smoke representativo e evidencia de alocacao, nao como claim +formal de throughput. + +## Native AOT / Trimming + +Smoke trimming gerado: + +- `dotnet publish ... -p:PublishTrimmed=true -p:DefineConstants=AOT_SMOKE_GENERATED`: sucesso; +- execucao do binario: `generated:ok`; +- warnings esperados: `IL2026` em `QueryMapped*` e `IL2104` em + `Dapper.FluentMap`/`Dapper`. + +Smoke Native AOT: + +- `dotnet publish ... -p:PublishAot=true -p:DefineConstants=AOT_SMOKE_GENERATED` + foi bloqueado pelo ambiente com `Platform linker not found`; +- antes do bloqueio, os warnings esperados `IL2026` e `IL3050` foram emitidos + nas chamadas `QueryMapped*`. + +Nao ha declaracao de compatibilidade Native AOT total. + +## Backward Compatibility + +- Nenhuma API publica existente foi removida. +- `IPropertyMap` permanece preservada; metadata vem por interface aditiva. +- Sem converter configurado, comportamento de Dapper puro, `QueryMapped*`, + Dommel e generated materializers permanece compativel. +- Correcoes de validacao podem rejeitar configuracoes contraditorias que antes + eram aceitas por acidente. + +## Known Limitations + +- Converters nao sao object mapper geral, serializer, SQL hook ou CRUD. +- Converter nao substitui `TypeHandler` global do Dapper. +- Write converters nao executam em Dommel/Dapper nesta etapa. +- Nao ha DI, factory publica ou escopo por query para converters. +- Overloads por tipo exigem construtor publico parameterless. +- Generated converter cobre apenas converter type estaticamente visivel e + suportado. +- TypeHandler no generated path permanece fora do escopo. +- `QueryMapped*` pode cair para runtime fallback e segue trimming/dynamic-code + sensitive. +- Null conversion opt-in nao existe. +- Write profiles nao existem. + +## Technical Debt + +- Definir hook publico de parametro por propriedade antes de executar write + conversion. +- Avaliar boundary publica para TypeHandlers no generated path sem depender de + internals do Dapper. +- Formalizar compatibilidade binaria/API antes de release maior. +- Criar benchmarks mais longos para comparacao estatistica, se houver decisao + de otimizacao. +- Investigar caminho generated-only/AOT-safe em etapa propria. + +## Deferred Items + +- Execucao de write converters em Dommel/Dapper. +- Parameter metadata (`DbType`, size, precision/scale, provider-specific). +- Factory/DI/scoped converter lifetime. +- Converter null opt-in. +- TypeHandler no generated path. +- Generated support para converter por instancia/delegate. +- Declaracao Native AOT alem dos smokes validados. + +## Recommendations for Etapa 11 + +- Se a Etapa 11 tratar configuracao/DI, separar claramente de write conversion. +- Projetar configuration instances/scoped configuration antes de qualquer + lifetime de converter por escopo. +- Para write conversion, decidir primeiro a API de parametro por propriedade e + preservar a fronteira do core sem CRUD/SQL builder. +- Manter TypeHandler como mecanismo por tipo e evitar registry global paralelo. +- Adicionar API compatibility tooling antes de preparar pacote publico maior. + +## Validation + +Executado em 2026-07-29: + +```bash +dotnet restore ./Dapper.FluentMap.sln +dotnet build ./Dapper.FluentMap.sln --configuration Release --no-restore +dotnet test ./Dapper.FluentMap.sln --configuration Release --no-build +dotnet test ./test/Dapper.FluentMap.Dommel.Tests/Dapper.FluentMap.Dommel.Tests.csproj --configuration Release --no-build +``` + +Resultados: + +- Restore: sucesso. +- Build Release: sucesso, 0 warnings, 0 errors. +- Solution tests: sucesso, 402 testes aprovados. +- Dommel tests isolados: sucesso, 22 testes aprovados. +- Benchmarks representativos: sucesso. +- Trimming smoke: sucesso com warnings esperados. +- Native AOT smoke: bloqueado pelo ambiente por ausencia do platform linker. +- Pack: nao executado no prompt 10.7; as alteracoes foram documentacao/SDD e + nao alteraram empacotamento ou assemblies. diff --git a/.sdd/etapa-10/STATUS.md b/.sdd/etapa-10/STATUS.md index 314f40f..e39b9cf 100644 --- a/.sdd/etapa-10/STATUS.md +++ b/.sdd/etapa-10/STATUS.md @@ -1,5 +1,9 @@ # Etapa 10 Status +Status: Concluída + +Último prompt executado: 10.7 + ## Objetivo Definir discovery, boundaries e arquitetura inicial para Property Conversion & @@ -111,7 +115,7 @@ tipo e abrindo espaco para conversao por propriedade, map e profile. Dommel mantendo write conversion metadata-only e validacao runtime de metadata externa invalida. -## Em andamento +## Itens adiados Write/Dommel conversion permanece bloqueada ate existir um hook publico de parametros por propriedade no Dommel ou uma API explicita no pacote de @@ -408,6 +412,32 @@ connection.Query() - `test/Dapper.FluentMap.AotSmoke/Program.cs` - `benchmarks/Dapper.FluentMap.Benchmarks/Program.cs` +## Validacao do Prompt 10.7 + +- `dotnet restore .\Dapper.FluentMap.sln`: sucesso. +- `dotnet build .\Dapper.FluentMap.sln --configuration Release --no-restore`: + sucesso, 0 warnings, 0 errors. +- `dotnet test .\Dapper.FluentMap.sln --configuration Release --no-build`: + sucesso, 402 testes aprovados no total. +- `dotnet test .\test\Dapper.FluentMap.Dommel.Tests\Dapper.FluentMap.Dommel.Tests.csproj --configuration Release --no-build`: + sucesso, 22 testes Dommel aprovados. +- `dotnet run --configuration Release --project .\benchmarks\Dapper.FluentMap.Benchmarks\Dapper.FluentMap.Benchmarks.csproj -- --filter "*MaterializationSteadyStateBenchmarks.QueryMapped*Converter*" --job Dry --warmupCount 1 --minIterationCount 1 --maxIterationCount 2`: + sucesso. Resultado observado: runtime property converter 1.390 ms / + 165.98 KB, generated property converter 1.421 ms / 166.55 KB, runtime no + converter 1.536 ms / 142.55 KB, runtime simple converter 2.036 ms / + 189.43 KB, generated simple converter 2.206 ms / 189.99 KB. +- `dotnet run --configuration Release --project .\benchmarks\Dapper.FluentMap.Benchmarks\Dapper.FluentMap.Benchmarks.csproj -- --filter "*MaterializationSteadyStateBenchmarks.DapperPure" --job Dry --warmupCount 1 --minIterationCount 1 --maxIterationCount 2`: + sucesso. Resultado observado: DapperPure 2.071 ms / 283.22 KB. +- `dotnet publish .\test\Dapper.FluentMap.AotSmoke\Dapper.FluentMap.AotSmoke.csproj --configuration Release -p:PublishTrimmed=true -p:DefineConstants=AOT_SMOKE_GENERATED --output .\.tmp\aot-smoke\generated-trimmed` seguido de execucao do binario: + sucesso, executavel retornou `generated:ok`; warnings esperados `IL2026` em + `QueryMapped*` e `IL2104` em `Dapper.FluentMap`/`Dapper`. +- `dotnet publish .\test\Dapper.FluentMap.AotSmoke\Dapper.FluentMap.AotSmoke.csproj --configuration Release -p:PublishAot=true -p:DefineConstants=AOT_SMOKE_GENERATED --output .\.tmp\aot-smoke\generated-aot`: + bloqueado pelo ambiente com `Platform linker not found`; antes do bloqueio + foram emitidos warnings esperados `IL2026` e `IL3050` nas chamadas + `QueryMapped*`. +- `dotnet pack`: nao executado no Prompt 10.7; as alteracoes foram + documentacao/SDD e nao mudaram assemblies ou empacotamento. + ## Ultimo prompt executado -Ultimo prompt executado: 10.6 +Ultimo prompt executado: 10.7 diff --git a/README.md b/README.md index 3171b10..1aaa99b 100644 --- a/README.md +++ b/README.md @@ -159,26 +159,125 @@ Map(product => product.Total) Computed properties participate in reads and are excluded from generated `INSERT` and `UPDATE` metadata. -### Property Read Conversion +## Property Converters -Property read converters can be attached to a mapping when a column value needs -property-specific conversion during FluentMap-controlled materialization: +Property converters are configured on a specific mapping when a column value +needs member-specific conversion. They are useful when two properties of the +same CLR type need different database representations, or when a mapping +profile reads a legacy SQL shape differently from the default map. + +```csharp +public sealed class ProductMap : EntityMap +{ + public ProductMap() + { + Map(product => product.Status) + .ToColumn("status_code") + .ConvertFromDatabaseUsing(); + } +} + +public sealed class ProductStatusConverter : + IReadPropertyConverter +{ + public ProductStatus ConvertFromDatabase(string value) + { + return value == "A" ? ProductStatus.Active : ProductStatus.Inactive; + } +} +``` + +Converter instances may be reused by concurrent materialization operations. +Implementations should be stateless or otherwise thread-safe. + +### Read Conversion + +Read conversion runs only in FluentMap-controlled materialization: +`QueryMapped*`, `ReadMapped*`, `QueryMultipleMapped`, synchronous unbuffered +streaming and asynchronous unbuffered streaming. + +For those APIs the effective precedence is: + +```text +null/DBNull handling + -> property read converter + -> Dapper TypeHandler + -> FluentMap default conversion +``` + +`null` and `DBNull.Value` are not passed to read converters by default. +Nullable/reference targets receive `null`; non-nullable value types receive +`default(T)`. Normal Dapper queries such as `Query()` are unchanged and do +not execute FluentMap property converters. + +Generated materializers can emit read converter calls for converter types that +are statically supported, accessible and parameterless: ```csharp Map(product => product.Status) - .ConvertFromDatabaseUsing() - .ConvertToDatabaseUsing(); + .ToColumn("status_code") + .ConvertFromDatabaseUsing(); ``` -`QueryMapped*`, `ReadMapped*`, `QueryMultipleMapped` and unbuffered streaming -apply read converters in the runtime materializer before falling back to a -Dapper `TypeHandler` or FluentMap's default conversion. Normal Dapper -queries (`Query()`) and Dommel write operations are unchanged; write -converter metadata is stored for a later parameter-conversion increment. +Converters supplied by instance or delegate continue to use the runtime +materializer fallback. + +### Converter Metadata For Writes + +The core package can store write converter metadata: + +```csharp +Map(product => product.Status) + .ToColumn("status_code") + .ConvertToDatabaseUsing(); +``` + +This does not currently convert parameters for Dapper or Dommel operations. +`Insert`, `Update` and other Dommel writes keep using the original entity values +and Dapper/provider parameter handling. Write converter execution is deferred +until there is a supported parameter-value hook. + +### Profiles + +Converters configured in a profile map apply only when that profile is selected: + +```csharp +public sealed class LegacyProductMap : + EntityMap, + IProfileMap +{ + public LegacyProductMap() + { + Map(product => product.Status) + .ToColumn("legacy_status") + .ConvertFromDatabaseUsing(); + } +} + +var product = connection.QueryMappedSingle( + "SELECT '1' AS legacy_status;"); +``` + +Default-map converters do not automatically leak into profiles. Reuse must be +explicit, for example through `IncludeBase()` or by configuring the converter +again in the profile map. + +### Dapper TypeHandlers + +Use a Dapper `TypeHandler` when a type has one database representation across +the application. Use a FluentMap property converter when the conversion belongs +to one mapping, member path or profile. + +```text +TypeHandler -> behavior by type +Property Converter -> behavior by mapping/member/profile +``` -Generated materializers can emit property read converter calls for statically -supported converter-type mappings. Converter instances and delegates continue to -use the runtime fallback. +When both are present on a FluentMap-controlled read, the property read +converter wins for that mapped property. Without a property converter, +`QueryMapped*` uses the registered `TypeHandler` before FluentMap's +default conversion. Generated materializers do not call Dapper TypeHandlers in +this stage; scenarios that depend on TypeHandlers use the runtime fallback. Inherited explicit mappings can be included when the derived entity should reuse a base entity map: @@ -589,6 +688,10 @@ persistence behavior that matches the intent: `ReadOnly()`, `Computed()`, - 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*` may use generated materializers for supported flat, nested and Value Object shapes, but it can still fall back to runtime metadata and dynamic code; it is not yet a guaranteed Native AOT-safe materialization path. +- Property converters are not a general object mapper, serializer, SQL hook or replacement for Dapper `TypeHandler`. +- Write converters are metadata-only in the current Dommel integration and are not executed by `Insert` or `Update`. +- Converter type overloads require a public parameterless constructor; instance and delegate overloads are the preferred runtime configuration forms when a converter needs explicit construction. +- Generated read conversion supports statically visible converter types; converter instances, delegates, inaccessible converter types and unsupported fluent patterns use runtime fallback. - Mapping profiles are selected through `QueryMapped()` and `ReadMapped()` APIs. - `QueryMapped*` and `ReadMapped*` are buffered. Use `QueryMappedUnbuffered*` for explicit synchronous or asynchronous unbuffered streaming. - `QueryMultipleMapped` consumes result sets sequentially and does not support concurrent reads from the same `MappedGridReader`. @@ -775,28 +878,127 @@ Map(product => product.Total) Propriedades computed participam de leituras e são excluídas da metadata de `INSERT` e `UPDATE` gerados. -### Conversao de Leitura por Propriedade +## Conversores de Propriedade -Conversores de leitura podem ser anexados a um mapping quando um valor de -coluna precisa de conversao especifica da propriedade durante materializacao -controlada pelo FluentMap: +Conversores de propriedade sao configurados em um mapping especifico quando um +valor de coluna precisa de conversao local ao membro. Eles sao uteis quando duas +propriedades do mesmo tipo CLR precisam de representacoes de banco diferentes, +ou quando um mapping profile le um shape SQL legado de forma diferente do map +default. + +```csharp +public sealed class ProductMap : EntityMap +{ + public ProductMap() + { + Map(product => product.Status) + .ToColumn("status_code") + .ConvertFromDatabaseUsing(); + } +} + +public sealed class ProductStatusConverter : + IReadPropertyConverter +{ + public ProductStatus ConvertFromDatabase(string value) + { + return value == "A" ? ProductStatus.Active : ProductStatus.Inactive; + } +} +``` + +Instancias de converter podem ser reutilizadas por materializacoes concorrentes. +Implementacoes devem ser stateless ou thread-safe. + +### Conversao de Leitura + +Conversao de leitura executa somente na materializacao controlada pelo +FluentMap: `QueryMapped*`, `ReadMapped*`, `QueryMultipleMapped`, streaming +unbuffered sincrono e streaming unbuffered assincrono. + +Para essas APIs, a precedencia efetiva e: + +```text +tratamento de null/DBNull + -> property read converter + -> Dapper TypeHandler + -> conversao padrao do FluentMap +``` + +`null` e `DBNull.Value` nao sao enviados aos read converters por default. +Targets nullable/reference recebem `null`; value types nao nullable recebem +`default(T)`. Consultas Dapper normais, como `Query()`, nao mudam e nao +executam converters de propriedade do FluentMap. + +Materializadores gerados podem emitir chamadas de read converter para converter +types suportados estaticamente, acessiveis e parameterless: ```csharp Map(product => product.Status) - .ConvertFromDatabaseUsing() - .ConvertToDatabaseUsing(); + .ToColumn("status_code") + .ConvertFromDatabaseUsing(); ``` -`QueryMapped*`, `ReadMapped*`, `QueryMultipleMapped` e streaming unbuffered -aplicam conversores de leitura no materializador de runtime antes de cair para -um `TypeHandler` do Dapper ou para a conversao padrao do FluentMap. -Consultas Dapper normais (`Query()`) e escritas Dommel nao mudam; metadata de -write converter fica armazenada para um incremento futuro de conversao de -parametros. +Converters fornecidos por instancia ou delegate continuam usando fallback do +materializador de runtime. + +### Metadata de Conversao para Escrita + +O pacote core consegue armazenar metadata de write converter: + +```csharp +Map(product => product.Status) + .ToColumn("status_code") + .ConvertToDatabaseUsing(); +``` + +Isso ainda nao converte parametros em operacoes Dapper ou Dommel. `Insert`, +`Update` e outras escritas Dommel continuam usando os valores originais da +entidade e a parametrizacao do Dapper/provider. A execucao de write converters +fica adiada ate existir um hook suportado para valores de parametros. + +### Profiles + +Converters configurados em um profile map valem somente quando aquele profile e +selecionado: + +```csharp +public sealed class LegacyProductMap : + EntityMap, + IProfileMap +{ + public LegacyProductMap() + { + Map(product => product.Status) + .ToColumn("legacy_status") + .ConvertFromDatabaseUsing(); + } +} + +var product = connection.QueryMappedSingle( + "SELECT '1' AS legacy_status;"); +``` + +Converters do map default nao vazam automaticamente para profiles. Reuso deve +ser explicito, por exemplo com `IncludeBase()` ou configurando o converter +novamente no profile map. + +### Dapper TypeHandlers + +Use um `TypeHandler` do Dapper quando um tipo tem uma representacao de banco +unica na aplicacao. Use um property converter do FluentMap quando a conversao +pertence a um mapping, member path ou profile especifico. + +```text +TypeHandler -> comportamento por tipo +Property Converter -> comportamento por mapping/member/profile +``` -Materializadores gerados podem emitir chamadas de read converter por propriedade -quando o mapping usa um converter por tipo suportado estaticamente. Converters -por instancia e delegate continuam usando runtime fallback. +Quando ambos existem em uma leitura controlada pelo FluentMap, o property read +converter tem precedencia naquela propriedade mapeada. Sem property converter, +`QueryMapped*` usa o `TypeHandler` registrado antes da conversao +padrao do FluentMap. Materializadores gerados nao chamam TypeHandlers do Dapper +nesta etapa; cenarios que dependem de TypeHandlers usam fallback runtime. Mapeamentos explícitos herdados podem ser incluídos quando a entidade derivada deve reutilizar um map da entidade base: @@ -1207,6 +1409,10 @@ ainda devem ser lidos, use o persistence behavior correspondente: - 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. +- Property converters nao sao object mapper geral, serializer, hook de SQL nem substituto para `TypeHandler` do Dapper. +- Write converters sao metadata-only na integracao Dommel atual e nao sao executados por `Insert` ou `Update`. +- Overloads por tipo de converter exigem construtor publico parameterless; overloads por instancia e delegate sao as formas preferidas de configuracao runtime quando o converter precisa de construcao explicita. +- Conversao de leitura gerada suporta converter types visiveis estaticamente; instancias, delegates, converter types inacessiveis e padroes fluent nao suportados usam fallback runtime. - `QueryMapped*` pode usar materializadores gerados para shapes flat, aninhados e Value Object suportados, mas ainda pode cair para metadados de runtime e código dinâmico; ele ainda não é um caminho de materialização garantidamente seguro para Native AOT. - Mapping profiles são selecionados pelas APIs `QueryMapped()` e `ReadMapped()`. - `QueryMapped*` e `ReadMapped*` são bufferizados. Use `QueryMappedUnbuffered*` para streaming unbuffered síncrono ou assíncrono explícito. From a6b2362f570b6f8d06f870255f7abec65c81630a Mon Sep 17 00:00:00 2001 From: Rodrigo de Oliveira Date: Wed, 29 Jul 2026 07:31:16 -0300 Subject: [PATCH 29/49] docs(sdd): define isolated configuration architecture --- .../00-configuration-state-discovery.md | 109 +++++++ .../01-historical-configuration-issues.md | 81 +++++ .../02-configuration-isolation-spec.md | 301 ++++++++++++++++++ .sdd/etapa-11/DECISIONS.md | 230 +++++++++++++ .sdd/etapa-11/STATUS.md | 140 ++++++++ 5 files changed, 861 insertions(+) create mode 100644 .sdd/etapa-11/00-configuration-state-discovery.md create mode 100644 .sdd/etapa-11/01-historical-configuration-issues.md create mode 100644 .sdd/etapa-11/02-configuration-isolation-spec.md create mode 100644 .sdd/etapa-11/DECISIONS.md create mode 100644 .sdd/etapa-11/STATUS.md diff --git a/.sdd/etapa-11/00-configuration-state-discovery.md b/.sdd/etapa-11/00-configuration-state-discovery.md new file mode 100644 index 0000000..98c3354 --- /dev/null +++ b/.sdd/etapa-11/00-configuration-state-discovery.md @@ -0,0 +1,109 @@ +# Configuration State Discovery + +## Escopo examinado + +Arquivos principais examinados no prompt 11.1: + +- `README.md` +- `Dapper.FluentMap.sln` +- `src/Dapper.FluentMap/FluentMapper.cs` +- `src/Dapper.FluentMap/Configuration/FluentMapConfiguration.cs` +- `src/Dapper.FluentMap/Configuration/FluentConventionConfiguration.cs` +- `src/Dapper.FluentMap/MappingRegistry.cs` +- `src/Dapper.FluentMap/Mapping/EntityMap.cs` +- `src/Dapper.FluentMap/Mapping/PropertyMap.cs` +- `src/Dapper.FluentMap/Mapping/PropertyPersistenceMetadata.cs` +- `src/Dapper.FluentMap/Mapping/PropertyConversionMetadata.cs` +- `src/Dapper.FluentMap/Materialization/*` +- `src/Dapper.FluentMap/QueryMappedExtensions.cs` +- `src/Dapper.FluentMap/MappedGridReader.cs` +- `src/Dapper.FluentMap/TypeMaps/*` +- `src/Dapper.FluentMap.Dommel/*` +- projetos de testes, analyzers, generators, AOT smoke e benchmarks por arquivos de projeto e pontos de integracao +- `.sdd/etapa-7/FINAL-REPORT.md`, `.sdd/etapa-8/FINAL-REPORT.md`, `.sdd/etapa-9/FINAL-REPORT.md` +- `.sdd/etapa-10/FINAL-REPORT.md` e `.sdd/etapa-10/STATUS.md` + +## Estado global identificado + +| Estado | Local | Classificacao | Mutacao atual | Thread safety atual | Observacoes | +| --- | --- | --- | --- | --- | --- | +| Registry default | `FluentMapper._registry` | Global compatibility state | Criado uma vez no processo | Instancia unica; containers internos sao concorrentes | Fonte efetiva de toda configuracao e cache do core. | +| Configuration default | `FluentMapper._configuration` | Global compatibility state | Reusado por todas as chamadas `Initialize` | Sem lock proprio | Fachada mutavel que escreve no registry global. | +| Default entity maps | `MappingRegistry.EntityMaps`, exposto como `FluentMapper.EntityMaps` | Configuration state + global compatibility state | `AddMap`, `TryAdd`, indexer, `Clear`, `Reset` | `ConcurrentDictionary`, mas valores sao mutaveis | API publica permite bypass de validacao, invalidacao de cache e instalacao de type map Dapper. | +| Profile maps | `MappingRegistry.ProfileMaps` | Configuration state | `AddProfileMap`, `Reset` | `ConcurrentDictionary`, valores mutaveis | Interno, mas ainda process-wide via registry global. | +| Type conventions | `MappingRegistry.TypeConventions`, exposto como `FluentMapper.TypeConventions` | Configuration state + global compatibility state | `AddConvention`, `Clear`, mutacao de `IList` | Dicionario concorrente; listas e conventions nao sao imutaveis | `AddConvention` substitui listas por copia, mas API publica expoe listas mutaveis. | +| Property map cache | `MappingRegistry._propertyMapCache` | Cache derived from configuration | Lazy `GetOrAdd`, clear/reset, invalidacao por tipo | `ConcurrentDictionary` | Chave contem tipo, profile, coluna e estrategia, mas nao versiona configuracao. | +| Runtime materialization plan cache | `MappingRegistry._materializationPlanCache` | Cache derived from configuration | Lazy `GetOrAdd`, clear/reset, invalidacao por tipo | `ConcurrentDictionary` | Chave contem tipo, profile e shape ordenado de colunas. Depende de maps, conventions, converters e metadata de persistence para ignore/read. | +| Generated materializer registry | `MappingRegistry._generatedMaterializers` | Runtime immutable state + cache derived from configuration | `AddGeneratedMaterializer`, clear/reset | `ConcurrentDictionary` | Descritores sao registrados durante configuracao, mas ficam no mesmo registry global. Validacao de match consulta mapping efetivo atual. | +| Dapper type maps | `SqlMapper.SetTypeMap` em `MappingRegistry.SetDapperTypeMap` e `Reset` | Process-wide integration state | Instalado ao registrar map/convention; removido no reset interno de testes | Estado global do Dapper | Limite central: `Query()` do Dapper escolhe type map por tipo, nao por configuracao FluentMap. | +| Dommel resolvers | `DommelMapper.Set*` em `ForDommel` | Process-wide integration state | Instalado por chamada a `ForDommel` | Estado global do Dommel | Resolvers atuais leem `FluentMapper.EntityMaps` e `TypeConventions` diretamente. | +| Dommel SQL builders | `DommelMapper.AddSqlBuilder` em `DommelPersistenceSqlBuilder.RegisterDefaults` | Process-wide integration state | Registrado por chamada a `ForDommel` | Estado global do Dommel | Substitui builders por chave de provider no processo. | +| Dommel default resolvers | `static readonly DefaultResolver` nos resolvers | Runtime immutable state | Nenhuma apos criacao | Seguro se os resolvers Dommel forem thread-safe | Estado estatico imutavel de fallback. | +| Dapper TypeHandler adapter | `DapperTypeHandlerAdapter` | Process-wide integration state | Consulta `SqlMapper.HasTypeHandler` e `TypeHandlerCache` | Depende de Dapper | Nao cria registry proprio, mas depende de estado global de TypeHandlers do Dapper. | +| Generated source converter fields | codigo emitido pelo generator | Runtime immutable state | Campos `static readonly` por materializer gerado | Thread-safe se converter for stateless | Pertencem ao assembly consumidor e sao registrados no registry global via `AddGeneratedMappings()`. | +| Static metadata defaults | `PropertyPersistenceMetadata.Default/Ignored`, `PropertyConversionMetadata.Default`, `NamingPolicy.Identity/SnakeCase` | Runtime immutable state | Nenhuma | Imutavel | Nao sao problema de isolamento. | +| Legacy `MultiTypeMap.TypePropertyMapCache` | `MultiTypeMap` | Cache derived from configuration, residual | Nao ha usos encontrados | `ConcurrentDictionary` | Cache legado aparentemente morto; deve ser revisado em hardening futuro. | + +## Mutações depois de Initialize + +O modelo atual nao possui transicao formal para configuracao imutavel. Depois de `Initialize` ainda e possivel: + +- chamar `Initialize` novamente e adicionar mapas, profiles, conventions e materializers gerados; +- mutar `FluentMapper.EntityMaps` diretamente com `TryAdd`, indexer, `Clear` ou operacoes de `ConcurrentDictionary`; +- mutar `FluentMapper.TypeConventions` diretamente e mutar as listas internas; +- mutar `IEntityMap.PropertyMaps`, porque o contrato publico expoe `IList`; +- mutar `Convention.PropertyMaps` e `Convention.ConventionConfigurations`, ambos `IList`; +- mutar instancias de `PropertyMapBase` durante a construcao do map via fluent API; se uma instancia vazar, nao ha congelamento; +- chamar `FluentMapper.Reset(...)` internamente nos testes, limpando registry e type maps do Dapper para os tipos informados; +- chamar `ForDommel()` novamente, reinstalando resolvers/builders globais. + +## Dependencia entre caches e configuracao + +- `_propertyMapCache` depende de maps explicitos, profiles, conventions, naming policies, `Ignored`, case sensitivity, inheritance e member path. +- `_materializationPlanCache` depende de maps, profiles, conventions, ordered column shape, persistence read semantics, converters, constructors, setters e Dapper TypeHandlers. +- `_generatedMaterializers` depende de descriptors registrados e do mapping efetivo no momento da consulta; a validacao faz fallback se o descriptor divergir. +- `SqlMapper.SetTypeMap` instala objetos que consultam `FluentMapper.Registry` em tempo de resolucao. Isso evita copiar toda configuracao no type map, mas prende `Query()` ao registry global. +- Dommel resolvers consultam os dicionarios publicos globais em tempo de resolucao, inclusive para persistence metadata. + +## Acessos diretos ao estado global + +### Core + +- `FluentMapConfiguration` escreve em `FluentMapper.Registry`. +- `FluentConventionConfiguration` escreve em `FluentMapper.Registry`. +- `MappedRowMaterializer` consulta `FluentMapper.Registry` para generated materializer e runtime plan. +- `FluentMapTypeMap`, `FluentMapTypeMap` interno e `FluentConventionTypeMap` resolvem propriedades via `FluentMapper.Registry`. +- `MappingRegistry` instala/remova type maps em `SqlMapper.SetTypeMap`. + +### Dommel + +- `DommelColumnNameResolver` consulta `FluentMapper.EntityMaps` e `FluentMapper.TypeConventions`. +- `DommelKeyPropertyResolver` consulta `FluentMapper.EntityMaps`. +- `DommelPropertyResolver` consulta `FluentMapper.EntityMaps`. +- `DommelTableNameResolver` consulta `FluentMapper.EntityMaps`. +- `DommelPersistenceMetadata` consulta `FluentMapper.EntityMaps`. +- `ForDommel()` instala resolvers/builders no `DommelMapper` global. + +### Testes + +- Os testes usam `FluentMapper.Reset(...)`, `EntityMaps.Clear()` e `TypeConventions.Clear()` para isolamento. +- Projetos de teste que tocam estado global desabilitam paralelismo com `CollectionBehavior(DisableTestParallelization = true)`. +- Varios testes inspecionam contadores internos de cache via `FluentMapper.Registry`. + +## APIs publicas que expoem colecoes mutaveis + +- `FluentMapper.EntityMaps`: `ConcurrentDictionary`. +- `FluentMapper.TypeConventions`: `ConcurrentDictionary>`. +- `IEntityMap.PropertyMaps`: `IList`. +- `EntityMapBase.PropertyMaps`: `IList`. +- `Convention.ConventionConfigurations`: `IList`. +- `Convention.PropertyMaps`: `IList`. + +## Riscos atuais + +- `ConcurrentDictionary` protege a estrutura do dicionario, mas nao congela os objetos armazenados. +- `ContainsKey` seguido de `TryAdd` evita duplicidade funcional, mas nao e uma transicao atomica de configuracao completa. +- A invalidacao por tipo nao cobre mutacao direta dos objetos de map/convention depois que caches foram preenchidos. +- Direct mutation em `EntityMaps` bypassa `SetDapperTypeMap`, comprovado por teste de compatibilidade. +- Dapper e Dommel mantem integracoes process-wide por tipo/provider; configuracoes multiplas nao conseguem dirigir `Query()` ou Dommel sem novos entry points ou bridges explicitas. +- Generated descriptors podem ficar registrados globalmente para uma configuracao que depois foi alterada; ha validacao de match, mas nao isolamento por instancia de configuracao. diff --git a/.sdd/etapa-11/01-historical-configuration-issues.md b/.sdd/etapa-11/01-historical-configuration-issues.md new file mode 100644 index 0000000..5bc17c4 --- /dev/null +++ b/.sdd/etapa-11/01-historical-configuration-issues.md @@ -0,0 +1,81 @@ +# Historical Configuration Issues + +## Issue #101 + +Fonte: https://github.com/henkmollema/Dapper-FluentMap/issues/101 + +### Problema original + +Em 2019-11-10, o usuario relatou leitura de dados de um banco para outro com nomes de colunas diferentes e pediu uma forma de resetar os mappings para o comportamento default. A issue foi fechada em 2020-07-24; o mantenedor respondeu que nao havia forma de fazer isso e que nao planejava adicionar a feature. + +### Causa arquitetural + +O problema e causado por configuracao process-wide. Uma vez que `FluentMapper.Initialize(...)` registra maps no estado global e instala type maps do Dapper por tipo, nao ha conceito de "configuracao A" para uma leitura e "configuracao B" para outra leitura no mesmo processo. Resetar o estado global resolveria apenas a troca serializada, nao consultas concorrentes, multi-tenant ou composicao de bibliotecas. + +### Estado atual no fork + +O fork possui um `FluentMapper.Reset(params Type[])` interno usado pelos testes. Esse reset limpa `EntityMaps`, `ProfileMaps`, `TypeConventions`, caches de property map, runtime materialization plan, generated materializers e remove type maps Dapper para os tipos informados. A API publica continua expondo `FluentMapper.EntityMaps` e `FluentMapper.TypeConventions`, entao consumidores ainda conseguem limpar dicionarios manualmente por compatibilidade. + +O estado atual melhora o isolamento de testes internos, mas nao resolve a causa estrutural. Caches e resolvers ainda dependem do registry global, e `QueryMapped*`, type maps Dapper e Dommel ainda leem esse estado global. + +### Solução simples possível + +Uma API publica como `FluentMapper.Reset()` ou `ClearConfiguration()` poderia: + +- limpar maps, profiles, conventions e generated materializers; +- limpar caches derivados; +- opcionalmente remover type maps Dapper para tipos conhecidos. + +Essa solucao e simples de descobrir, mas continuaria process-wide. Ela tambem seria perigosa com queries concorrentes e dificil de tornar correta sem controlar todos os tipos ja instalados em `SqlMapper`. + +### Solução estrutural recomendada + +Introduzir um modelo isolado: + +```text +builder mutavel + -> configuracao imutavel + -> runtime/context com caches por configuracao + -> APIs QueryMapped/runtime que recebem ou pertencem a esse runtime +``` + +A API estatica deve virar camada de compatibilidade que possui um runtime default, em vez de continuar sendo a implementacao principal. O reset deve permanecer ferramenta de compatibilidade/teste, nao solucao arquitetural para multiplas configuracoes. + +### Decisão para Etapa 11 + +Na Etapa 11, a direcao e especificar e implementar incrementalmente configuracoes imutaveis e runtime isolado. `FluentMapper.Initialize(...)` deve continuar funcionando, mas como bridge para o runtime default. Nao remover a API estatica e nao promover `Reset()` como API principal. + +## Issue #79 + +Fonte: https://github.com/henkmollema/Dapper-FluentMap/issues/79 + +### Problema original + +Em 2018-11-08, um usuario executava `FluentMapper.Initialize(...)` dentro de um `UnitOfWork` registrado como transient em ASP.NET Core e recebia erro de mapa duplicado. Ele perguntou se haveria forma de "dispose mappings". + +### Comentarios relevantes + +O mantenedor explicou que `Initialize` deveria rodar uma vez no startup. Tambem sugeriu limpar `FluentMapper.EntityMaps` e `FluentMapper.TypeConventions` como workaround e reconheceu que limpar em cada `Initialize` seria perturbador porque usuarios poderiam depender do comportamento aditivo. Uma possivel API `ClearConfiguration` foi citada como mais descobrivel. + +### Leitura arquitetural + +#79 mostra dois contratos historicos importantes: + +- `Initialize` aditivo e chamado em startup virou comportamento esperado; +- limpar colecoes globais era workaround aceito, mas nao seguro para concorrencia nem suficiente para Dapper/Dommel/caches. + +## Issue #84 + +Fonte: https://github.com/henkmollema/Dapper-FluentMap/issues/84 + +### Problema original + +Em 2019-03-07, um usuario de testes de integracao ASP.NET Core relatou falhas quando mais de um teste iniciava a aplicacao em memoria e cada startup tentava registrar os mesmos maps. Ele comparou com a API por instancia/DI do AutoMapper e pediu inicializacao por instancia. + +### Comentarios relevantes + +Foi sugerido limpar mappings antes de inicializar, mas o reporter observou falhas aleatorias com mais testes paralelos. O mantenedor respondeu que FluentMap configura type maps do Dapper, que nao sao consumidos por DI como `IMapper`, e que nao via forma de usar FluentMap concorrentemente; normalmente desabilitava execucao paralela nesses testes. + +### Leitura arquitetural + +#84 e a evidencia historica mais direta para a Etapa 11: reset/clear nao resolve paralelismo. A arquitetura precisa aceitar configuracao isolada para APIs controladas pelo FluentMap, e documentar que a integracao process-wide do Dapper puro e do Dommel requer bridge separada. diff --git a/.sdd/etapa-11/02-configuration-isolation-spec.md b/.sdd/etapa-11/02-configuration-isolation-spec.md new file mode 100644 index 0000000..83fbbf1 --- /dev/null +++ b/.sdd/etapa-11/02-configuration-isolation-spec.md @@ -0,0 +1,301 @@ +# Configuration Isolation Specification + +## Objetivos + +- Separar configuracao mutavel de runtime de consulta. +- Permitir construir uma configuracao imutavel reutilizavel. +- Permitir mais de uma configuracao FluentMap no mesmo processo para APIs que possam receber runtime/context. +- Preservar `FluentMapper.Initialize(...)` como camada de compatibilidade. +- Reduzir dependencia de reset global para testes. +- Tornar caches derivados escopados por runtime/configuracao. +- Preparar integracao futura com DI e ASP.NET Core sem Service Locator e sem `AsyncLocal` ambient. + +## Nao objetivos + +- Remover a API estatica nesta etapa. +- Transformar o core em ORM, repository, Unit of Work, connection factory ou SQL generator. +- Resolver multi-tenancy de aplicacao como framework completo. +- Tornar `Dapper.Query()` capaz de escolher configuracao por chamada sem novo contrato do Dapper. +- Tornar Dommel completamente isolado sem avaliar seus extension points globais. +- Declarar Native AOT completo para `QueryMapped*`. +- Executar write converters em Dommel/Dapper. + +## Configuration Builder + +Conceito recomendado: um builder mutavel com a DSL atual de registro. + +Nome conceitual preferido apos leitura da API: `FluentMapConfigurationBuilder`. + +Responsabilidades: + +- receber `AddMap`, `AddProfile`, `AddConvention`, naming policies e generated materializers; +- executar validacoes de configuracao; +- ordenar includes de base; +- guardar descritores mutaveis somente ate `Build()`; +- produzir uma configuracao imutavel. + +`FluentMapConfiguration` hoje e uma fachada mutavel. Para compatibilidade, ela pode ser mantida como tipo historico e gradualmente redirecionada para o builder, ou virar wrapper temporario sobre o builder default. + +## Immutable Configuration + +Nome conceitual recomendado: `FluentMapConfiguration`. + +Responsabilidades: + +- conter snapshots imutaveis de default maps, profile maps, conventions/naming policies, generated descriptors, persistence metadata e converter metadata; +- nao expor colecoes mutaveis; +- ser segura para compartilhamento entre threads; +- ser registravel como singleton em DI; +- ser independente de caches lazy de materializacao. + +Regra: + +```text +mutable builder + -> Build() +immutable configuration +``` + +Apos `Build()`, maps, profiles, conventions e converter metadata nao mudam. Caches derivados podem ser lazy, desde que estejam em runtime/context thread-safe. + +## Runtime Context + +Nome conceitual recomendado: `FluentMapRuntime`. + +Responsabilidades: + +- possuir uma `FluentMapConfiguration`; +- possuir caches derivados por configuracao; +- resolver property maps, profile maps, conventions e Dapper default fallback; +- criar materializers runtime; +- localizar generated materializers; +- produzir diagnostics/explain; +- oferecer APIs de consulta opt-in ou ser passado a elas. + +O runtime nao deve possuir conexao, transacao, comando ou SQL. Ele deve ser singleton quando sua configuracao for imutavel. + +## Global Compatibility Layer + +`FluentMapper` deve continuar existindo. A direcao e: + +```text +FluentMapper + -> default builder/configuration bridge + -> default FluentMapRuntime +``` + +A camada estatica nao deve duplicar a implementacao. Ela deve delegar ao mesmo runtime usado por APIs instanciadas. + +Compatibilidade a preservar: + +- `Initialize(Action)` continua aditivo por padrao, porque #79 registrou essa expectativa; +- `EntityMaps` e `TypeConventions` continuam existindo por compatibilidade, mas devem ser desencorajados e eventualmente tratados como view/adapter legado; +- `Validate`, `Explain`, `GetEntityMaps` e `GetTypeConventions` continuam funcionando sobre o runtime default; +- `Reset` interno pode permanecer para testes e bridge de compatibilidade, sem virar solucao principal. + +## Query Integration + +APIs atuais `QueryMapped*`, `ReadMapped*`, `QueryMultipleMapped`, streaming sync e async hoje usam `FluentMapper.Registry`. + +Evolucao proposta: + +- manter overloads atuais usando runtime default; +- adicionar novos entry points que recebam `FluentMapRuntime` explicitamente ou sejam metodos de extensao sobre um query context; +- evitar `AsyncLocal` para selecionar configuracao implicitamente; +- nao alterar semantica de buffering/streaming existente; +- manter generated-then-runtime fallback por runtime. + +Exemplo conceitual: + +```csharp +var runtime = configuration.CreateRuntime(); +var rows = connection.QueryMapped(runtime, sql); +``` + +## Profiles + +Profiles continuam query-scoped e escolhidos por `TProfile` nos metodos existentes. + +No modelo isolado: + +- profile maps ficam na configuracao imutavel; +- caches usam chave `runtime/configuration + entity + profile + column shape`; +- profile nao instala type map Dapper global; +- maps default nao vazam para profiles salvo composicao explicita, como hoje. + +## Generated Materializers + +Generated materializers devem pertencer a configuracao ou ao runtime criado dela. + +Direcao: + +- generator continua emitindo `AddGeneratedMappings()` para compatibilidade; +- em API nova, o codigo gerado deve registrar descriptors no builder; +- descriptors devem ser congelados em `Build()`; +- lookup generated deve ser por runtime/configuracao, entity, profile e ordered column shape; +- validacao contra mapping efetivo deve usar a configuracao do runtime, nao `FluentMapper.Registry`. + +Campos estaticos gerados para converter types continuam aceitaveis quando stateless/thread-safe. + +## Property Converters + +Converter metadata deve ser parte da configuracao imutavel. + +Direcao: + +- overloads por tipo continuam criando instancia durante configuracao, como hoje; +- instancias/delegates configurados continuam pertencendo a metadata do map; +- contrato deve manter exigencia de thread-safety/stateless porque runtime singleton pode reutilizar instancias; +- DI/factory de converter e item futuro e so deve ser adicionado apos existir runtime/configuration isolation. + +## Persistence Metadata + +Persistence metadata pertence ao snapshot imutavel de property maps. + +Direcao: + +- core continua metadata-only para write; +- Dommel continua pacote que interpreta persistence metadata; +- `Ignore()` continua unica semantica que remove materializacao; +- write converters continuam metadata-only ate haver hook de parametros por propriedade. + +## Diagnostics + +Diagnostics devem ser expostos pelo runtime/configuracao: + +- `Validate()` em configuracao ou runtime; +- `Explain()` usando a configuracao do runtime; +- diagnostics generated devem contar descriptors do runtime selecionado; +- camada estatica delega ao runtime default. + +## Dommel + +Dommel e o ponto mais sensivel para isolamento porque seus resolvers sao instalados globalmente em `DommelMapper`. + +Direcao para Etapa 11: + +- nao prometer isolamento completo para APIs Dommel existentes sem novos extension points; +- manter `ForDommel()` como bridge de compatibilidade global; +- extrair resolvers para dependerem de um provider de runtime/configuracao quando possivel; +- avaliar uma API futura que instale Dommel contra o runtime default explicitamente; +- documentar que multiplas configuracoes Dommel no mesmo processo nao sao plenamente suportadas enquanto `DommelMapper` for global. + +## Caching + +Caches derivados devem sair do estado global e morar no runtime. + +Caches candidatos: + +- property map cache; +- materialization plan cache; +- generated materializer lookup/index; +- diagnostics/explain cache, se for criado no futuro. + +Chaves devem incluir todos os fatores que alteram resultado: entity type, profile type, column name, ordered column shape, estrategia de resolucao e a identidade implicita do runtime/configuracao. Se o cache mora dentro do runtime, nao precisa incluir id de configuracao na chave. + +## Thread Safety + +Modelo desejado: + +- builder nao e thread-safe e deve ser usado na inicializacao; +- configuracao imutavel e thread-safe; +- runtime e thread-safe; +- caches lazy usam `ConcurrentDictionary` ou outra primitiva equivalente; +- maps/conventions congelados nao podem ser alterados apos build; +- chamadas de query podem rodar em paralelo quando usam runtimes distintos ou o mesmo runtime imutavel. + +## Multiple Configurations + +A arquitetura deve permitir: + +```text +Configuration A -> Runtime A -> Database A +Configuration B -> Runtime B -> Database B +``` + +Sem colisao de mapping state para APIs controladas pelo FluentMap. + +Limite importante: `Dapper.Query()` puro e Dommel existente seguem por integracao process-wide. Para multiplas configuracoes, sera necessario usar entry points FluentMap que recebam runtime ou criar bridges especificas. + +## Test Isolation + +Novos testes devem poder criar `FluentMapConfigurationBuilder`, chamar `Build()`, criar `FluentMapRuntime` e consultar sem tocar `FluentMapper.Reset`. + +Testes de compatibilidade estatica podem continuar serializados e usando reset interno. A meta e reduzir, nao apagar em um unico passo, a dependencia global. + +## Dependency Injection + +Pacote futuro de DI deve registrar configuracao e runtime como singletons quando a configuracao for imutavel. + +Lifetimes recomendados: + +- singleton: `FluentMapConfiguration`; +- singleton: `FluentMapRuntime`; +- scoped: wrappers que agreguem runtime + recursos scoped da aplicacao, se houver necessidade real; +- transient: query context leve quando ele apenas carrega runtime e opcoes por chamada. + +Nao usar `Scoped` por reflexo de ASP.NET Core. Mapping metadata imutavel e cache de materializacao sao recursos naturalmente singleton. + +## ASP.NET Core + +Direcao futura: + +```csharp +services.AddFluentMap(builder => +{ + builder.AddMap(); +}); +``` + +Essa API deve: + +- construir configuracao uma vez durante composition root; +- registrar runtime singleton; +- nao registrar connection factory propria; +- nao depender de service locator; +- permitir que testes de integracao criem service providers independentes sem colisao no runtime FluentMap; +- deixar claro que Dapper type maps globais e Dommel global exigem opt-in separado. + +## Trimming / Native AOT + +Direcao: + +- preservar anotacoes de APIs que usam scanning, reflection e runtime fallback; +- manter registro explicito e gerado como caminhos preferidos; +- favorecer descriptors imutaveis e generated registration por builder; +- evitar reflection scanning no caminho DI por default; +- nao declarar `QueryMapped*` AOT-safe enquanto houver fallback runtime possivel; +- nao introduzir ativacao reflection-only para converter factories. + +## Backward Compatibility + +Compromissos: + +- nenhuma API estatica removida na Etapa 11; +- `Initialize` continua funcionando; +- comportamento aditivo de `Initialize` deve ser preservado inicialmente; +- colecoes publicas mutaveis continuam por compatibilidade, mas novas APIs devem evitar esse padrao; +- `SqlMapper.SetTypeMap` continua sendo usado pela bridge estatica para compatibilidade com `Dapper.Query()`; +- alteracoes de comportamento publico exigem teste e nota de migracao. + +## Migration Strategy + +Sequencia recomendada: + +1. Introduzir builder/configuration/runtime internos ou publicos aditivos. +2. Mover logica do `MappingRegistry` para runtime isolado. +3. Adaptar `QueryMapped*` para usar runtime default e adicionar overloads por runtime. +4. Reescrever `FluentMapper` como bridge de compatibilidade. +5. Adicionar DI em pacote/namespace separado. +6. Migrar testes para runtime isolado onde possivel. +7. Endurecer Dommel e documentar limites. + +## Performance + +Expectativa: + +- Build pode ter custo maior por congelar snapshots e validar composicao. +- Query hot path deve manter caches lazy e generated dispatch existentes. +- Runtime singleton permite amortizar caches. +- Configuracoes multiplas duplicam caches por runtime, como esperado. +- Evitar copiar grandes estruturas por query. diff --git a/.sdd/etapa-11/DECISIONS.md b/.sdd/etapa-11/DECISIONS.md new file mode 100644 index 0000000..8100fd8 --- /dev/null +++ b/.sdd/etapa-11/DECISIONS.md @@ -0,0 +1,230 @@ +# Etapa 11 Architectural Decisions + +## ADR-1 - Builder vs mutable global configuration + +### Contexto + +Hoje `FluentMapConfiguration` escreve diretamente em `FluentMapper.Registry`, que e global. Isso impede configuracoes independentes e faz reset parecer solucao. + +### Decisao + +Introduzir um builder mutavel separado do runtime. A configuracao global vira apenas uma bridge para o builder/runtime default. + +### Alternativas consideradas + +- Manter `Initialize` como unica API. +- Criar apenas `Reset()` publico. +- Fazer `Initialize` limpar sempre a configuracao anterior. + +### Consequencias + +O modelo fica mais previsivel para DI e testes. A compatibilidade exige preservar o comportamento aditivo atual na camada estatica. + +## ADR-2 - Immutable configuration + +### Contexto + +Maps, conventions e property maps sao mutaveis e expostos por `IList`. + +### Decisao + +`Build()` deve produzir snapshot imutavel de maps, profiles, conventions, generated descriptors, converters e persistence metadata. + +### Alternativas consideradas + +- Continuar usando `ConcurrentDictionary` como "imutabilidade suficiente". +- Clonar somente no momento do cache. + +### Consequencias + +Reduz races e elimina invalidacao por mutacao tardia. Pode exigir descritores internos imutaveis mesmo preservando interfaces publicas mutaveis. + +## ADR-3 - Runtime/context abstraction + +### Contexto + +`MappedRowMaterializer` e type maps consultam `FluentMapper.Registry` diretamente. + +### Decisao + +Criar um runtime/context que contem configuracao imutavel e caches derivados. + +### Alternativas consideradas + +- Passar dicionarios avulsos para cada API. +- Usar `AsyncLocal` para escolher configuracao. + +### Consequencias + +APIs novas podem receber runtime explicitamente. Evita estado ambiente escondido. + +## ADR-4 - Global compatibility layer + +### Contexto + +Consumidores existentes usam `FluentMapper.Initialize(...)` e `Dapper.Query()`. + +### Decisao + +Manter `FluentMapper` como bridge para o runtime default e para `SqlMapper.SetTypeMap`. + +### Alternativas consideradas + +- Remover API estatica. +- Manter duas implementacoes independentes. + +### Consequencias + +Compatibilidade e preservada, mas a camada estatica continua com limites process-wide. + +## ADR-5 - Configuration-specific caches + +### Contexto + +Caches atuais ficam no registry global e dependem da configuracao efetiva. + +### Decisao + +Mover caches para o runtime. As chaves continuam focadas em tipo, profile, coluna e shape porque o runtime ja identifica a configuracao. + +### Alternativas consideradas + +- Manter caches globais com generation/version id. +- Limpar caches em toda mutacao. + +### Consequencias + +Configuracoes simultaneas nao colidem. O custo de memoria cresce por runtime. + +## ADR-6 - Multiple configurations + +### Contexto + +Issue #101 pede alternar mappings entre bancos diferentes. + +### Decisao + +Suportar multiplas configuracoes nas APIs FluentMap opt-in que possam receber runtime/context. + +### Alternativas consideradas + +- Reset global entre operacoes. +- Chavear configuracao por connection string. + +### Consequencias + +Uso concorrente fica possivel sem colisao nos caminhos controlados pelo FluentMap. Dapper puro e Dommel permanecem limitados por estado global. + +## ADR-7 - DI lifetime + +### Contexto + +Issue #84 relaciona ASP.NET Core e inicializacao por instancia. + +### Decisao + +Configuration e runtime devem ser singleton. Wrappers scoped/transient so devem existir se carregarem recursos por request ou opcoes por chamada. + +### Alternativas consideradas + +- Runtime scoped por request. +- Builder registrado no container. + +### Consequencias + +Caches sao reaproveitados e a configuracao imutavel e compartilhada com seguranca. + +## ADR-8 - Dommel interaction + +### Contexto + +Dommel usa resolvers/builders globais em `DommelMapper`. + +### Decisao + +Tratar Dommel como bridge process-wide inicialmente. Nao prometer multiplas configuracoes Dommel no mesmo processo ate existir design especifico. + +### Alternativas consideradas + +- Tentar esconder runtime por `AsyncLocal`. +- Reimplementar Dommel ou SQL generation no core. + +### Consequencias + +O escopo fica honesto. O core pode evoluir isolamento sem transformar Dommel em ORM proprio. + +## ADR-9 - Generated materializer registration + +### Contexto + +Generated materializers sao registrados no registry global e validados contra mapping efetivo atual. + +### Decisao + +Descriptors gerados devem ser registrados no builder/configuracao e indexados no runtime. + +### Alternativas consideradas + +- Registry global separado por assembly. +- Gerar codigo que chama diretamente APIs estaticas. + +### Consequencias + +Generated e runtime usam o mesmo isolamento. A extensao `AddGeneratedMappings()` pode continuar retornando o builder/configuration para compatibilidade. + +## ADR-10 - Backward compatibility + +### Contexto + +`Initialize` aditivo, dicionarios publicos e type maps Dapper globais sao comportamento historico. + +### Decisao + +A Etapa 11 deve ser aditiva. Mudancas em API estatica devem ser bridgeadas e testadas por compatibilidade. + +### Alternativas consideradas + +- Major breaking change imediata. +- Descontinuar dicionarios publicos sem adaptador. + +### Consequencias + +A migracao e mais longa, mas consumivel por biblioteca publica. + +## ADR-11 - Reset semantics + +### Contexto + +#79, #84 e #101 mostram demanda por clear/reset, mas tambem os riscos de concorrencia. + +### Decisao + +Reset nao e solucao principal. Manter reset interno/teste e avaliar API publica somente como ferramenta de compatibilidade bem documentada. + +### Alternativas consideradas + +- Expor `FluentMapper.Reset()` publico como feature central. +- Fazer clear automatico em `Initialize`. + +### Consequencias + +A arquitetura ataca a causa do estado global. Testes antigos ainda podem usar reset ate migrarem. + +## ADR-12 - Native AOT implications + +### Contexto + +Etapas 7 a 10 validaram trimming parcial e mantiveram warnings em `QueryMapped*`. + +### Decisao + +Isolamento de configuracao deve favorecer registro explicito/gerado e snapshots, mas nao remover warnings AOT enquanto houver fallback runtime reflection/dynamic code. + +### Alternativas consideradas + +- Declarar runtime isolado como AOT-safe. +- Remover fallback runtime para forcar generated-only. + +### Consequencias + +Compatibilidade e preservada. Um caminho generated-only/AOT-safe deve ser decisao futura separada. diff --git a/.sdd/etapa-11/STATUS.md b/.sdd/etapa-11/STATUS.md new file mode 100644 index 0000000..bca330a --- /dev/null +++ b/.sdd/etapa-11/STATUS.md @@ -0,0 +1,140 @@ +# Etapa 11 Status + +## Objetivo + +Definir discovery e arquitetura para Configuration Isolation & Dependency Injection, preservando a API estatica historica como camada de compatibilidade e preparando configuracoes imutaveis com runtime isolado. + +## Concluido + +- Executado `git status` antes de alteracoes. +- Confirmada branch `feature/etapa-3`; nao estamos em `master`. +- Identificado item nao rastreado preexistente `src/Dapper.FluentMap/etapas/`, deixado intacto. +- Lido `README.md`. +- Examinada `Dapper.FluentMap.sln`. +- Examinados core, Dommel, analyzers, generators, testes, AOT smoke e benchmarks nos pontos relacionados a configuracao, registry, caches, materializacao, query APIs, generated materializers, converters, persistence metadata e diagnostics. +- Lidos `.sdd/etapa-10/FINAL-REPORT.md` e `.sdd/etapa-10/STATUS.md`. +- Consultados relatórios finais/status das Etapas 7, 8 e 9. +- Confirmado que `.sdd/etapa-11/` nao existia e criada a pasta. +- Pesquisadas issues historicas #101, #79 e #84 no projeto original. +- Criado `00-configuration-state-discovery.md`. +- Criado `01-historical-configuration-issues.md`. +- Criado `02-configuration-isolation-spec.md`. +- Criado `DECISIONS.md`. +- Criado este `STATUS.md`. + +## Em andamento + +- Revisao final de diff. +- Commit semantico do SDD. + +## Proximos passos + +1. Implementar modelo inicial de builder/configuracao imutavel sem mudar behavior publico. +2. Extrair runtime isolado a partir de `MappingRegistry`. +3. Adaptar `QueryMapped*`/`MappedGridReader` para runtime default e planejar overloads por runtime. +4. Reescrever `FluentMapper` como bridge de compatibilidade, preservando `Initialize` aditivo. +5. Projetar DI em incremento separado. +6. Migrar testes de isolamento/concurrencia para runtime instanciado. +7. Endurecer documentacao e limites de Dommel/Dapper process-wide. + +## Decisoes relevantes + +- Builder mutavel deve ser separado da configuracao imutavel. +- `Build()` e o limite apos o qual maps, conventions, profiles e converter metadata nao mudam. +- Caches derivados pertencem ao runtime, nao ao estado global. +- `FluentMapper` deve delegar ao runtime default. +- `Initialize` deve continuar aditivo inicialmente. +- `Reset` nao e solucao arquitetural principal. +- DI deve registrar configuracao e runtime como singleton. +- Dommel permanece bridge process-wide ate design especifico. +- Native AOT nao deve ser prometido alem do que os smokes validam. + +## Estado global identificado + +- `FluentMapper._registry`. +- `FluentMapper._configuration`. +- `FluentMapper.EntityMaps`. +- `FluentMapper.TypeConventions`. +- `MappingRegistry.ProfileMaps`. +- `MappingRegistry._propertyMapCache`. +- `MappingRegistry._materializationPlanCache`. +- `MappingRegistry._generatedMaterializers`. +- `SqlMapper.SetTypeMap` por entidade. +- `DommelMapper.SetColumnNameResolver`, `SetKeyPropertyResolver`, `SetTableNameResolver`, `SetPropertyResolver`. +- `DommelMapper.AddSqlBuilder`. +- Estado global de Dapper TypeHandlers consultado por `DapperTypeHandlerAdapter`. +- Campos estaticos generated por assembly consumidor. +- `MultiTypeMap.TypePropertyMapCache` legado sem uso encontrado. + +## APIs afetadas + +- `FluentMapper.Initialize`. +- `FluentMapper.Validate`. +- `FluentMapper.Explain`. +- `FluentMapper.GetEntityMaps`. +- `FluentMapper.GetTypeConventions`. +- `FluentMapper.EntityMaps`. +- `FluentMapper.TypeConventions`. +- `FluentMapConfiguration`. +- `FluentConventionConfiguration`. +- `QueryMapped*`. +- `QueryMultipleMapped`. +- `MappedGridReader.ReadMapped*`. +- `QueryMappedUnbuffered*`. +- `GeneratedMaterializerDescriptor` registration. +- `AddGeneratedMappings()` emitido pelo generator. +- `ForDommel()`. +- Resolvers Dommel. + +## Backward compatibility + +- Nenhuma API estatica deve ser removida na Etapa 11. +- `Initialize` aditivo deve ser preservado na bridge estatica. +- Dicionarios publicos mutaveis devem continuar existindo por compatibilidade, mas novas APIs devem expor snapshots/imutabilidade. +- `Dapper.Query()` deve continuar funcionando para o runtime default por `SqlMapper.SetTypeMap`. +- APIs novas por runtime devem ser opt-in. + +## Riscos conhecidos + +- Dapper type maps sao globais por tipo e nao selecionam runtime por chamada. +- Dommel resolvers/builders sao globais. +- Mutacao direta de dicionarios publicos bypassa validacao, invalidacao e instalacao de type map. +- Interfaces publicas expõem `IList`, dificultando congelamento sem descritores internos. +- Caches atuais nao possuem generation id e dependem de invalidacao por tipo. +- Converter instances podem ser reutilizadas concorrentemente. +- Generated materializers por instancia/delegate ainda usam fallback runtime. +- Native AOT completo continua fora do contrato atual. + +## Arquivos importantes + +- `.sdd/etapa-11/00-configuration-state-discovery.md` +- `.sdd/etapa-11/01-historical-configuration-issues.md` +- `.sdd/etapa-11/02-configuration-isolation-spec.md` +- `.sdd/etapa-11/DECISIONS.md` +- `.sdd/etapa-11/STATUS.md` +- `src/Dapper.FluentMap/FluentMapper.cs` +- `src/Dapper.FluentMap/Configuration/FluentMapConfiguration.cs` +- `src/Dapper.FluentMap/Configuration/FluentConventionConfiguration.cs` +- `src/Dapper.FluentMap/MappingRegistry.cs` +- `src/Dapper.FluentMap/Materialization/MappedRowMaterializer.cs` +- `src/Dapper.FluentMap/Materialization/NestedMaterializationPlan.cs` +- `src/Dapper.FluentMap/QueryMappedExtensions.cs` +- `src/Dapper.FluentMap/MappedGridReader.cs` +- `src/Dapper.FluentMap/TypeMaps/FluentMapTypeMap.cs` +- `src/Dapper.FluentMap/TypeMaps/FluentTypeMap.cs` +- `src/Dapper.FluentMap.Dommel/FluentMapConfigurationExtensions.cs` +- `src/Dapper.FluentMap.Dommel/Resolvers/*` +- `test/Dapper.FluentMap.Tests/ConfigurationLifecycleTests.cs` +- `test/Dapper.FluentMap.Tests/MappingStateEncapsulationTests.cs` +- `test/Dapper.FluentMap.Tests/MappingRegistryTests.cs` + +## Validacao do Prompt 11.1 + +- `dotnet restore ./Dapper.FluentMap.sln`: sucesso. +- `dotnet build ./Dapper.FluentMap.sln --configuration Release --no-restore`: sucesso, 0 warnings, 0 errors. +- `dotnet test ./Dapper.FluentMap.sln --configuration Release --no-build`: sucesso, 402 testes aprovados no total. +- `dotnet pack`: nao executado; este prompt alterou somente documentacao SDD e nao mudou empacotamento ou assemblies. + +## Último prompt executado + +Último prompt executado: 11.1 From 07e6b964b99aaea7e835ef31f55e7fd0074e0c4d Mon Sep 17 00:00:00 2001 From: Rodrigo de Oliveira Date: Wed, 29 Jul 2026 07:49:10 -0300 Subject: [PATCH 30/49] feat(configuration): add immutable configuration model --- .sdd/etapa-11/03-configuration-model.md | 204 ++++++++ .sdd/etapa-11/DECISIONS.md | 28 ++ .sdd/etapa-11/STATUS.md | 96 ++-- README.md | 38 ++ .../FluentConventionConfiguration.cs | 23 +- .../Configuration/FluentMapConfiguration.cs | 43 +- .../FluentMapConfigurationBuilder.cs | 247 +++++++++ .../ImmutableFluentMapConfiguration.cs | 409 +++++++++++++++ src/Dapper.FluentMap/MappingRegistry.cs | 27 + .../ImmutableConfigurationModelTests.cs | 473 ++++++++++++++++++ 10 files changed, 1537 insertions(+), 51 deletions(-) create mode 100644 .sdd/etapa-11/03-configuration-model.md create mode 100644 src/Dapper.FluentMap/Configuration/FluentMapConfigurationBuilder.cs create mode 100644 src/Dapper.FluentMap/Configuration/ImmutableFluentMapConfiguration.cs create mode 100644 test/Dapper.FluentMap.Tests/ImmutableConfigurationModelTests.cs diff --git a/.sdd/etapa-11/03-configuration-model.md b/.sdd/etapa-11/03-configuration-model.md new file mode 100644 index 0000000..6a61a4e --- /dev/null +++ b/.sdd/etapa-11/03-configuration-model.md @@ -0,0 +1,204 @@ +# Configuration Model + +## Objetivo + +O modelo inicial da etapa 11 introduz a fronteira: + +```text +FluentMapConfigurationBuilder mutavel + -> Build() +ImmutableFluentMapConfiguration imutavel +``` + +Sem migrar ainda todos os entry points de runtime. A API estatica +`FluentMapper.Initialize(...)` continua sendo a bridge de compatibilidade global. + +## Builder API + +`FluentMapConfigurationBuilder` fica em `Dapper.FluentMap.Configuration` e +reusa a DSL historica de registro: + +- `AddMap(IEntityMap)`; +- `AddMap()`; +- `AddProfile()`; +- `AddConvention().ForEntity()`; +- `UseNamingPolicy(...).ForEntity()`; +- `AddGeneratedMaterializer(...)`; +- `AddMapsFromAssembly(...)` e `AddMapsFromAssemblyContaining(...)`; +- `Configure(Action)`. + +`Configure(...)` existe para reaproveitar extensoes existentes sobre +`FluentMapConfiguration`, incluindo o `AddGeneratedMappings()` emitido pelo +source generator. A extensao gerada nao precisa conhecer o singleton global: a +fachada passada pelo builder escreve no registry isolado do builder. + +## Configuration API + +`ImmutableFluentMapConfiguration` e o snapshot efetivo produzido por `Build()`. +Ele expoe somente colecoes read-only: + +- `EntityMaps`: default maps por tipo de entidade; +- `ProfileMaps`: maps profile-scoped; +- `TypeConventions`: conventions e naming policies por entidade; +- `GeneratedMaterializers`: descriptors gerados por entidade/profile/shape. + +O tipo historico `FluentMapConfiguration` permanece mutavel porque faz parte da +API publica existente e e usado por `FluentMapper.Initialize(...)`. Nesta etapa, +ele foi desacoplado do singleton por um registry injetado internamente. + +## Lifecycle + +1. O consumidor cria um `FluentMapConfigurationBuilder`. +2. O builder recebe registros mutaveis durante startup/composition root. +3. `Build()` executa validacao usando a mesma fonte de regras do runtime. +4. `Build()` cria um snapshot read-only. +5. Depois de `Build()`, o builder fica selado. +6. Chamadas posteriores a `Build()` retornam a mesma instancia imutavel. + +O builder nao e thread-safe. A configuracao imutavel resultante e segura para +leituras concorrentes. + +## Mutation Boundaries + +O limite de mutacao e o primeiro `Build()`. + +Depois disso: + +- chamadas mutadoras no builder lancam `InvalidOperationException`; +- objetos `FluentConventionConfiguration` obtidos antes do build tambem rejeitam + `ForEntity(...)`/scanning; +- o snapshot nao expoe `IEntityMap`, `Convention` nem listas mutaveis como + configuracao efetiva; +- mutacao tardia de uma instancia de map usada no builder nao altera o snapshot. + +`FluentMapper.EntityMaps`, `FluentMapper.TypeConventions` e +`IEntityMap.PropertyMaps` continuam mutaveis por compatibilidade, mas nao sao o +modelo recomendado para novas configuracoes imutaveis. + +## Validation + +`Build()` chama `MappingRegistry.ValidateConfiguration()`. Essa e a mesma fonte +usada por `FluentMapper.Validate()`. + +Nao ha uma segunda arvore de regras para `configuration.Validate()`. A validacao +de invariants continua centralizada em: + +- `MappingConfigurationValidator`; +- composicao/checagem de include base em `MappingRegistry`; +- validacao de generated materializer descriptor no registro. + +## Mappings + +Default maps sao capturados como `EntityMappingConfiguration`: + +- entity type; +- concrete map type; +- property maps; +- included base types. + +Property maps sao copiados para `PropertyMappingConfiguration`, preservando: + +- member path; +- terminal `PropertyInfo`; +- column name; +- case sensitivity; +- ignored; +- persistence metadata; +- conversion metadata. + +## Conventions + +Conventions sao aplicadas no builder pela mesma logica existente em +`FluentConventionConfiguration`. O snapshot captura `ConventionType` e os +`PropertyMappingConfiguration` gerados para a entidade. + +Conventions nao sao expostas como instancias mutaveis no snapshot. + +## Naming + +Naming policies continuam implementadas como `NamingPolicyConvention`. +`UseNamingPolicy(...)` retorna a mesma configuracao de convention historica, mas +apontando para o registry isolado do builder. O snapshot captura os property maps +resultantes e sua configuracao de case sensitivity. + +## Profiles + +Profiles sao capturados separadamente em `ProfileMappingConfiguration`: + +- entity type; +- profile type; +- concrete map type; +- property maps; +- included base types. + +Profiles continuam query-scoped conceitualmente. Esta etapa nao altera +`QueryMapped()`. + +## Converters + +Property converter metadata faz parte do snapshot de cada property map. + +O modelo preserva a regra da etapa 10: + +```text +property converter -> metadata por mapping/member/profile +``` + +As instancias/delegates de converter continuam encapsuladas na metadata +existente. Consumidores devem tratar converters como stateless/thread-safe. + +## Persistence Metadata + +Persistence metadata tambem e copiada para cada `PropertyMappingConfiguration`. +O core continua metadata-only para writes. Dommel permanece o pacote que +interpreta essa metadata em SQL gerado. + +## Generated Registrations + +Generated materializers sao registrados no builder pelo mesmo contrato +`AddGeneratedMaterializer(...)`. O snapshot captura: + +- entity type; +- profile type opcional; +- ordered column shape; +- delegate interno do materializer. + +O generator atual pode ser usado com: + +```csharp +var configuration = new FluentMapConfigurationBuilder() + .Configure(config => config.AddGeneratedMappings()) + .Build(); +``` + +Isso evita que o generated registration precise conhecer `FluentMapper` como +singleton global. + +## Duplicate Detection + +Duplicate maps, duplicate profiles e duplicate generated materializers continuam +rejeitados no momento de registro pelo `MappingRegistry`. + +Duplicidades dentro de maps/conventions e conflitos de coluna continuam +validados por `MappingConfigurationValidator`, inclusive quando um map mutavel e +alterado depois do registro mas antes do `Build()`. + +## Inheritance + +`IncludeBase()` continua validado durante registro/build pela mesma +composicao existente em `MappingRegistry`. O snapshot preserva os tipos base +incluidos para que o runtime isolado futuro consiga compor metadata sem reler +objetos mutaveis. + +## Thread Safety + +Modelo desta etapa: + +- builder: mutavel, nao thread-safe, uso de startup; +- immutable configuration: read-only snapshot, seguro para leituras concorrentes; +- runtime atual: ainda usa `FluentMapper.Registry` para APIs existentes; +- runtime isolado futuro: deve consumir o snapshot e manter caches por runtime. + +As colecoes sao `ReadOnlyDictionary`/`ReadOnlyCollection`, nao `FrozenDictionary`, +porque o pacote principal permanece em `netstandard2.0` e a etapa nao aumenta +TFMs nem adiciona dependencias. diff --git a/.sdd/etapa-11/DECISIONS.md b/.sdd/etapa-11/DECISIONS.md index 8100fd8..8d1c777 100644 --- a/.sdd/etapa-11/DECISIONS.md +++ b/.sdd/etapa-11/DECISIONS.md @@ -228,3 +228,31 @@ Isolamento de configuracao deve favorecer registro explicito/gerado e snapshots, ### Consequencias Compatibilidade e preservada. Um caminho generated-only/AOT-safe deve ser decisao futura separada. + +## ADR-13 - Naming do modelo inicial + +### Contexto + +`FluentMapConfiguration` ja e uma API publica mutavel usada por +`FluentMapper.Initialize(...)` e por extensoes existentes. Trocar esse tipo por +uma configuracao imutavel nesta etapa quebraria compatibilidade de fonte e +provavelmente binaria. + +### Decisao + +Introduzir `FluentMapConfigurationBuilder` como builder publico novo e +`ImmutableFluentMapConfiguration` como snapshot imutavel publico. Manter +`FluentMapConfiguration` como fachada historica mutavel, mas desacopla-la do +singleton por um `MappingRegistry` injetado internamente. + +### Alternativas consideradas + +- Renomear ou transformar `FluentMapConfiguration` diretamente em imutavel. +- Criar um segundo builder com DSL propria independente. +- Exigir que o source generator conheca o singleton global. + +### Consequencias + +A etapa fica aditiva e preserva `Initialize`. O builder consegue reutilizar +extensoes existentes via `Configure(Action)`, enquanto +o snapshot evita expor maps/conventions mutaveis como configuracao efetiva. diff --git a/.sdd/etapa-11/STATUS.md b/.sdd/etapa-11/STATUS.md index bca330a..95868a5 100644 --- a/.sdd/etapa-11/STATUS.md +++ b/.sdd/etapa-11/STATUS.md @@ -2,7 +2,9 @@ ## Objetivo -Definir discovery e arquitetura para Configuration Isolation & Dependency Injection, preservando a API estatica historica como camada de compatibilidade e preparando configuracoes imutaveis com runtime isolado. +Definir discovery e arquitetura para Configuration Isolation & Dependency +Injection, preservando a API estatica historica como camada de compatibilidade +e preparando configuracoes imutaveis com runtime isolado. ## Concluido @@ -13,36 +15,44 @@ Definir discovery e arquitetura para Configuration Isolation & Dependency Inject - Examinada `Dapper.FluentMap.sln`. - Examinados core, Dommel, analyzers, generators, testes, AOT smoke e benchmarks nos pontos relacionados a configuracao, registry, caches, materializacao, query APIs, generated materializers, converters, persistence metadata e diagnostics. - Lidos `.sdd/etapa-10/FINAL-REPORT.md` e `.sdd/etapa-10/STATUS.md`. -- Consultados relatórios finais/status das Etapas 7, 8 e 9. -- Confirmado que `.sdd/etapa-11/` nao existia e criada a pasta. -- Pesquisadas issues historicas #101, #79 e #84 no projeto original. +- Consultados relatorios finais/status das Etapas 7, 8 e 9. - Criado `00-configuration-state-discovery.md`. - Criado `01-historical-configuration-issues.md`. - Criado `02-configuration-isolation-spec.md`. +- Criado `03-configuration-model.md`. - Criado `DECISIONS.md`. - Criado este `STATUS.md`. +- Validado que as ADRs existentes continuam compativeis com o incremento 11.2. +- Adicionada ADR-13 para o naming do modelo inicial. +- Implementado `FluentMapConfigurationBuilder`. +- Implementado `ImmutableFluentMapConfiguration` com snapshots read-only de maps, profiles, conventions/naming, generated materializers, persistence metadata e converter metadata. +- `FluentMapConfiguration` e `FluentConventionConfiguration` foram desacopladas do singleton global por registry injetado internamente, preservando os construtores/APIs publicas existentes. +- `MappingRegistry` agora pode operar sem instalar type maps globais do Dapper, permitindo builders independentes sem colisao process-wide. +- Criados testes de empty configuration, single map, multiple maps, convention, naming, inheritance, profiles, converters, generated registrations, duplicate maps, invalid map, Build, immutability, independent configurations e concurrent reads. ## Em andamento - Revisao final de diff. -- Commit semantico do SDD. +- Commit semantico. ## Proximos passos -1. Implementar modelo inicial de builder/configuracao imutavel sem mudar behavior publico. -2. Extrair runtime isolado a partir de `MappingRegistry`. -3. Adaptar `QueryMapped*`/`MappedGridReader` para runtime default e planejar overloads por runtime. -4. Reescrever `FluentMapper` como bridge de compatibilidade, preservando `Initialize` aditivo. -5. Projetar DI em incremento separado. -6. Migrar testes de isolamento/concurrencia para runtime instanciado. -7. Endurecer documentacao e limites de Dommel/Dapper process-wide. +1. Extrair runtime isolado a partir de `MappingRegistry`. +2. Adaptar `QueryMapped*`/`MappedGridReader` para runtime default e planejar overloads por runtime. +3. Reescrever `FluentMapper` como bridge de compatibilidade, preservando `Initialize` aditivo. +4. Projetar DI em incremento separado. +5. Migrar testes de isolamento/concurrencia para runtime instanciado. +6. Endurecer documentacao e limites de Dommel/Dapper process-wide. ## Decisoes relevantes - Builder mutavel deve ser separado da configuracao imutavel. - `Build()` e o limite apos o qual maps, conventions, profiles e converter metadata nao mudam. -- Caches derivados pertencem ao runtime, nao ao estado global. -- `FluentMapper` deve delegar ao runtime default. +- `FluentMapConfigurationBuilder` e o builder publico inicial. +- `ImmutableFluentMapConfiguration` e o snapshot imutavel publico inicial. +- `FluentMapConfiguration` permanece a fachada mutavel historica por compatibilidade. +- Caches derivados pertencem ao runtime futuro, nao ao estado global. +- `FluentMapper` deve delegar ao runtime default em incremento futuro. - `Initialize` deve continuar aditivo inicialmente. - `Reset` nao e solucao arquitetural principal. - DI deve registrar configuracao e runtime como singleton. @@ -76,23 +86,25 @@ Definir discovery e arquitetura para Configuration Isolation & Dependency Inject - `FluentMapper.EntityMaps`. - `FluentMapper.TypeConventions`. - `FluentMapConfiguration`. +- `FluentMapConfigurationBuilder`. +- `ImmutableFluentMapConfiguration`. +- `EntityMappingConfiguration`. +- `ProfileMappingConfiguration`. +- `ConventionMappingConfiguration`. +- `PropertyMappingConfiguration`. +- `GeneratedMaterializerConfiguration`. - `FluentConventionConfiguration`. -- `QueryMapped*`. -- `QueryMultipleMapped`. -- `MappedGridReader.ReadMapped*`. -- `QueryMappedUnbuffered*`. - `GeneratedMaterializerDescriptor` registration. - `AddGeneratedMappings()` emitido pelo generator. -- `ForDommel()`. -- Resolvers Dommel. ## Backward compatibility -- Nenhuma API estatica deve ser removida na Etapa 11. -- `Initialize` aditivo deve ser preservado na bridge estatica. -- Dicionarios publicos mutaveis devem continuar existindo por compatibilidade, mas novas APIs devem expor snapshots/imutabilidade. -- `Dapper.Query()` deve continuar funcionando para o runtime default por `SqlMapper.SetTypeMap`. -- APIs novas por runtime devem ser opt-in. +- Nenhuma API estatica foi removida na Etapa 11. +- `Initialize` aditivo foi preservado. +- Dicionarios publicos mutaveis continuam existindo por compatibilidade, mas novas APIs expõem snapshots/imutabilidade. +- `Dapper.Query()` continua funcionando para o runtime default por `SqlMapper.SetTypeMap`. +- O builder novo nao instala type maps globais do Dapper. +- APIs novas por runtime continuam futuras e opt-in. ## Riscos conhecidos @@ -100,7 +112,7 @@ Definir discovery e arquitetura para Configuration Isolation & Dependency Inject - Dommel resolvers/builders sao globais. - Mutacao direta de dicionarios publicos bypassa validacao, invalidacao e instalacao de type map. - Interfaces publicas expõem `IList`, dificultando congelamento sem descritores internos. -- Caches atuais nao possuem generation id e dependem de invalidacao por tipo. +- Runtime isolado ainda nao foi extraido; APIs `QueryMapped*` atuais continuam no registry default. - Converter instances podem ser reutilizadas concorrentemente. - Generated materializers por instancia/delegate ainda usam fallback runtime. - Native AOT completo continua fora do contrato atual. @@ -110,31 +122,33 @@ Definir discovery e arquitetura para Configuration Isolation & Dependency Inject - `.sdd/etapa-11/00-configuration-state-discovery.md` - `.sdd/etapa-11/01-historical-configuration-issues.md` - `.sdd/etapa-11/02-configuration-isolation-spec.md` +- `.sdd/etapa-11/03-configuration-model.md` - `.sdd/etapa-11/DECISIONS.md` - `.sdd/etapa-11/STATUS.md` -- `src/Dapper.FluentMap/FluentMapper.cs` +- `README.md` +- `src/Dapper.FluentMap/Configuration/FluentMapConfigurationBuilder.cs` +- `src/Dapper.FluentMap/Configuration/ImmutableFluentMapConfiguration.cs` - `src/Dapper.FluentMap/Configuration/FluentMapConfiguration.cs` - `src/Dapper.FluentMap/Configuration/FluentConventionConfiguration.cs` - `src/Dapper.FluentMap/MappingRegistry.cs` -- `src/Dapper.FluentMap/Materialization/MappedRowMaterializer.cs` -- `src/Dapper.FluentMap/Materialization/NestedMaterializationPlan.cs` -- `src/Dapper.FluentMap/QueryMappedExtensions.cs` -- `src/Dapper.FluentMap/MappedGridReader.cs` -- `src/Dapper.FluentMap/TypeMaps/FluentMapTypeMap.cs` -- `src/Dapper.FluentMap/TypeMaps/FluentTypeMap.cs` -- `src/Dapper.FluentMap.Dommel/FluentMapConfigurationExtensions.cs` -- `src/Dapper.FluentMap.Dommel/Resolvers/*` -- `test/Dapper.FluentMap.Tests/ConfigurationLifecycleTests.cs` -- `test/Dapper.FluentMap.Tests/MappingStateEncapsulationTests.cs` -- `test/Dapper.FluentMap.Tests/MappingRegistryTests.cs` +- `test/Dapper.FluentMap.Tests/ImmutableConfigurationModelTests.cs` ## Validacao do Prompt 11.1 - `dotnet restore ./Dapper.FluentMap.sln`: sucesso. - `dotnet build ./Dapper.FluentMap.sln --configuration Release --no-restore`: sucesso, 0 warnings, 0 errors. - `dotnet test ./Dapper.FluentMap.sln --configuration Release --no-build`: sucesso, 402 testes aprovados no total. -- `dotnet pack`: nao executado; este prompt alterou somente documentacao SDD e nao mudou empacotamento ou assemblies. +- `dotnet pack`: nao executado; o prompt 11.1 alterou somente documentacao SDD e nao mudou empacotamento ou assemblies. -## Último prompt executado +## Validacao do Prompt 11.2 -Último prompt executado: 11.1 +- `dotnet build ./src/Dapper.FluentMap/Dapper.FluentMap.csproj --configuration Release`: sucesso, 0 warnings, 0 errors. +- `dotnet test ./test/Dapper.FluentMap.Tests/Dapper.FluentMap.Tests.csproj --configuration Release`: sucesso, 347 testes aprovados. +- `dotnet restore ./Dapper.FluentMap.sln`: sucesso. +- `dotnet build ./Dapper.FluentMap.sln --configuration Release --no-restore`: sucesso, 0 warnings, 0 errors. +- `dotnet test ./Dapper.FluentMap.sln --configuration Release --no-build`: sucesso, 419 testes aprovados no total. +- `dotnet pack`: nao executado; o prompt 11.2 nao alterou empacotamento nem metadata de pacote. + +## Ultimo prompt executado + +Ultimo prompt executado: 11.2 diff --git a/README.md b/README.md index 1aaa99b..932f67c 100644 --- a/README.md +++ b/README.md @@ -327,6 +327,25 @@ 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. +You can also build an immutable configuration snapshot without mutating the +global FluentMapper state: + +```csharp +using Dapper.FluentMap.Configuration; + +var configuration = new FluentMapConfigurationBuilder() + .AddMap() + .Configure(config => config.AddGeneratedMappings()) + .Build(); +``` + +`Build()` validates the same invariants used by `FluentMapper.Validate()` and +returns an `ImmutableFluentMapConfiguration` with read-only metadata for maps, +profiles, conventions, naming policies, persistence metadata, converters and +generated materializer registrations. The builder is sealed after `Build()`. +Existing runtime APIs still use the global compatibility layer until isolated +runtime entry points are introduced. + ## Conventions and Naming Policies Conventions let you map repeated column patterns: @@ -1048,6 +1067,25 @@ 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. +Tambem e possivel construir um snapshot imutavel sem alterar o estado global do +`FluentMapper`: + +```csharp +using Dapper.FluentMap.Configuration; + +var configuration = new FluentMapConfigurationBuilder() + .AddMap() + .Configure(config => config.AddGeneratedMappings()) + .Build(); +``` + +`Build()` valida os mesmos invariants usados por `FluentMapper.Validate()` e +retorna um `ImmutableFluentMapConfiguration` com metadata read-only para maps, +profiles, conventions, naming policies, persistence metadata, converters e +generated materializer registrations. O builder fica selado depois de `Build()`. +As APIs de runtime existentes ainda usam a camada global de compatibilidade ate +que entry points de runtime isolado sejam introduzidos. + ## Convenções e Políticas de Nomenclatura Convenções permitem mapear padrões repetidos de colunas: diff --git a/src/Dapper.FluentMap/Configuration/FluentConventionConfiguration.cs b/src/Dapper.FluentMap/Configuration/FluentConventionConfiguration.cs index 1ccc42e..b7156d1 100644 --- a/src/Dapper.FluentMap/Configuration/FluentConventionConfiguration.cs +++ b/src/Dapper.FluentMap/Configuration/FluentConventionConfiguration.cs @@ -17,7 +17,9 @@ 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 MappingRegistry _registry; private readonly Convention _convention; + private readonly Action _ensureMutable; /// /// Initializes a new instance of the class, @@ -25,13 +27,20 @@ public class FluentConventionConfiguration /// /// The convention. public FluentConventionConfiguration(Convention convention) + : this(convention, FluentMapper.Registry, ensureMutable: null) + { + } + + internal FluentConventionConfiguration(Convention convention, MappingRegistry registry, Action ensureMutable) { if (convention == null) { throw new ArgumentNullException(nameof(convention)); } + _registry = registry ?? throw new ArgumentNullException(nameof(registry)); _convention = convention; + _ensureMutable = ensureMutable; } /// @@ -43,10 +52,11 @@ public FluentConventionConfiguration ForEntity< [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicProperties)] T>() { + EnsureCanMutate(); var type = typeof(T); MapProperties(type); - FluentMapper.Registry.AddConvention(type, _convention); + _registry.AddConvention(type, _convention); return this; } @@ -62,6 +72,7 @@ public FluentConventionConfiguration ForEntity< [RequiresUnreferencedCode(AssemblyScanningRequiresUnreferencedCodeMessage)] public FluentConventionConfiguration ForEntitiesInCurrentAssembly(params string[] namespaces) { + EnsureCanMutate(); foreach (var type in Assembly.GetCallingAssembly().GetExportedTypes()) { if (namespaces != null && @@ -73,7 +84,7 @@ public FluentConventionConfiguration ForEntitiesInCurrentAssembly(params string[ } MapProperties(type); - FluentMapper.Registry.AddConvention(type, _convention); + _registry.AddConvention(type, _convention); } return this; @@ -92,6 +103,7 @@ public FluentConventionConfiguration ForEntitiesInCurrentAssembly(params string[ [RequiresUnreferencedCode(AssemblyScanningRequiresUnreferencedCodeMessage)] public FluentConventionConfiguration ForEntitiesInAssembly(Assembly assembly, params string[] namespaces) { + EnsureCanMutate(); foreach (var type in assembly.GetExportedTypes()) { if (namespaces != null && @@ -103,12 +115,17 @@ public FluentConventionConfiguration ForEntitiesInAssembly(Assembly assembly, pa } MapProperties(type); - FluentMapper.Registry.AddConvention(type, _convention); + _registry.AddConvention(type, _convention); } return this; } + private void EnsureCanMutate() + { + _ensureMutable?.Invoke(); + } + private void MapProperties( [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicProperties)] Type type) diff --git a/src/Dapper.FluentMap/Configuration/FluentMapConfiguration.cs b/src/Dapper.FluentMap/Configuration/FluentMapConfiguration.cs index eb0f3e7..ac84ae3 100644 --- a/src/Dapper.FluentMap/Configuration/FluentMapConfiguration.cs +++ b/src/Dapper.FluentMap/Configuration/FluentMapConfiguration.cs @@ -19,6 +19,23 @@ 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."; + private readonly MappingRegistry _registry; + private readonly Action _ensureMutable; + + /// + /// Initializes a new instance of the class. + /// + public FluentMapConfiguration() + : this(FluentMapper.Registry, ensureMutable: null) + { + } + + internal FluentMapConfiguration(MappingRegistry registry, Action ensureMutable) + { + _registry = registry ?? throw new ArgumentNullException(nameof(registry)); + _ensureMutable = ensureMutable; + } + /// /// Adds the specified to the configuration of Dapper.FluentMap. /// @@ -34,7 +51,8 @@ public void AddMap(IEntityMap mapper) where TEntity : class throw new ArgumentNullException(nameof(mapper)); } - FluentMapper.Registry.AddEntityMap(mapper); + EnsureCanMutate(); + _registry.AddEntityMap(mapper); } /// @@ -51,7 +69,8 @@ public FluentMapConfiguration AddMap< var entityType = GetMappedEntityType(mapType); var mapper = CreateEntityMap(); - FluentMapper.Registry.AddEntityMap(entityType, mapper); + EnsureCanMutate(); + _registry.AddEntityMap(entityType, mapper); return this; } @@ -70,7 +89,8 @@ public FluentMapConfiguration AddProfile< var profileType = GetMappedProfileType(mapType); var mapper = CreateEntityMap(); - FluentMapper.Registry.AddProfileMap(entityType, profileType, mapper); + EnsureCanMutate(); + _registry.AddProfileMap(entityType, profileType, mapper); return this; } @@ -121,7 +141,8 @@ public FluentMapConfiguration AddGeneratedMaterializer( throw new ArgumentNullException(nameof(descriptor)); } - FluentMapper.Registry.AddGeneratedMaterializer(descriptor); + EnsureCanMutate(); + _registry.AddGeneratedMaterializer(descriptor); return this; } @@ -139,6 +160,7 @@ public FluentMapConfiguration AddMapsFromAssembly(Assembly assembly, params stri throw new ArgumentNullException(nameof(assembly)); } + EnsureCanMutate(); var definitions = FindEntityMapDefinitions(assembly, namespaces).ToList(); EnsureNoDuplicateEntityMaps(definitions); @@ -151,7 +173,7 @@ public FluentMapConfiguration AddMapsFromAssembly(Assembly assembly, params stri foreach (var registration in OrderByIncludedBaseMaps(registrations)) { - FluentMapper.Registry.AddEntityMap(registration.EntityType, registration.Map); + _registry.AddEntityMap(registration.EntityType, registration.Map); } return this; @@ -180,7 +202,8 @@ public FluentMapConfiguration AddMapsFromAssemblyContaining(params stri /// public FluentConventionConfiguration AddConvention() where TConvention : Convention, new() { - return new FluentConventionConfiguration(new TConvention()); + EnsureCanMutate(); + return new FluentConventionConfiguration(new TConvention(), _registry, EnsureCanMutate); } /// @@ -199,7 +222,8 @@ public FluentConventionConfiguration UseNamingPolicy(NamingPolicy namingPolicy, throw new ArgumentNullException(nameof(namingPolicy)); } - return new FluentConventionConfiguration(new NamingPolicyConvention(namingPolicy, caseSensitive)); + EnsureCanMutate(); + return new FluentConventionConfiguration(new NamingPolicyConvention(namingPolicy, caseSensitive), _registry, EnsureCanMutate); } /// @@ -343,6 +367,11 @@ private static IEntityMap CreateEntityMap() } } + private void EnsureCanMutate() + { + _ensureMutable?.Invoke(); + } + private static void EnsureNoDuplicateEntityMaps(IList definitions) { var duplicates = definitions diff --git a/src/Dapper.FluentMap/Configuration/FluentMapConfigurationBuilder.cs b/src/Dapper.FluentMap/Configuration/FluentMapConfigurationBuilder.cs new file mode 100644 index 0000000..ee71e60 --- /dev/null +++ b/src/Dapper.FluentMap/Configuration/FluentMapConfigurationBuilder.cs @@ -0,0 +1,247 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Reflection; +using Dapper.FluentMap.Conventions; +using Dapper.FluentMap.Mapping; +using Dapper.FluentMap.Materialization; +using Dapper.FluentMap.Naming; + +namespace Dapper.FluentMap.Configuration +{ + /// + /// Builds an immutable FluentMap configuration from the existing registration DSL. + /// + /// + /// The builder is mutable and intended for startup/composition-root use. After + /// is called, further mutation through the builder is rejected and subsequent calls return the same + /// immutable configuration instance. + /// + public sealed class FluentMapConfigurationBuilder + { + private const string AssemblyScanningRequiresUnreferencedCodeMessage = + "Assembly scanning discovers entity maps by reflection. Register maps explicitly with AddMap() when publishing trimmed or Native AOT applications."; + + private readonly MappingRegistry _registry; + private readonly FluentMapConfiguration _configuration; + private ImmutableFluentMapConfiguration _builtConfiguration; + + /// + /// Initializes a new instance of the class. + /// + public FluentMapConfigurationBuilder() + { + _registry = new MappingRegistry(installDapperTypeMaps: false); + _configuration = new FluentMapConfiguration(_registry, EnsureNotBuilt); + } + + /// + /// Applies existing FluentMap registration extensions to this builder. + /// + /// The registration callback that uses the historical configuration DSL. + /// The current builder. + public FluentMapConfigurationBuilder Configure(Action configure) + { + if (configure == null) + { + throw new ArgumentNullException(nameof(configure)); + } + + EnsureNotBuilt(); + configure(_configuration); + return this; + } + + /// + /// Adds the specified entity map to the configuration. + /// + /// The mapped entity type. + /// The entity map instance. + /// The current builder. + public FluentMapConfigurationBuilder AddMap(IEntityMap mapper) + where TEntity : class + { + EnsureNotBuilt(); + _configuration.AddMap(mapper); + return this; + } + + /// + /// Adds a new instance of the specified entity map type to the configuration. + /// + /// The entity map type to create and register. + /// The current builder. + public FluentMapConfigurationBuilder AddMap< + [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.Interfaces)] + TMap>() + where TMap : IEntityMap, new() + { + EnsureNotBuilt(); + _configuration.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 builder. + public FluentMapConfigurationBuilder AddProfile< + [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.Interfaces)] + TMap>() + where TMap : IEntityMap, new() + { + EnsureNotBuilt(); + _configuration.AddProfile(); + return this; + } + + /// + /// Registers a generated materializer for the default mapping of the specified entity type. + /// + /// The entity type produced by the materializer. + /// The ordered column shape and member bindings expected by the materializer. + /// The generated row materializer. + /// The current builder. + public FluentMapConfigurationBuilder AddGeneratedMaterializer( + IEnumerable columns, + GeneratedRowMaterializer materializer) + where TEntity : class + { + EnsureNotBuilt(); + _configuration.AddGeneratedMaterializer(columns, materializer); + return this; + } + + /// + /// Registers a generated materializer for the specified entity type and mapping profile. + /// + /// The entity type produced by the materializer. + /// The mapping profile marker type used by the materializer. + /// The ordered column shape and member bindings expected by the materializer. + /// The generated row materializer. + /// The current builder. + public FluentMapConfigurationBuilder AddGeneratedMaterializer( + IEnumerable columns, + GeneratedRowMaterializer materializer) + where TEntity : class + where TProfile : IMappingProfile + { + EnsureNotBuilt(); + _configuration.AddGeneratedMaterializer(columns, materializer); + return this; + } + + /// + /// Registers a generated materializer descriptor. + /// + /// The entity type produced by the materializer. + /// The generated materializer descriptor. + /// The current builder. + public FluentMapConfigurationBuilder AddGeneratedMaterializer( + GeneratedMaterializerDescriptor descriptor) + where TEntity : class + { + EnsureNotBuilt(); + _configuration.AddGeneratedMaterializer(descriptor); + return this; + } + + /// + /// Finds exported entity map types in the specified assembly and adds them to the configuration. + /// + /// The assembly to scan for entity maps. + /// Optional namespaces used to filter discovered entity map types. + /// The current builder. + [RequiresUnreferencedCode(AssemblyScanningRequiresUnreferencedCodeMessage)] + public FluentMapConfigurationBuilder AddMapsFromAssembly(Assembly assembly, params string[] namespaces) + { + EnsureNotBuilt(); + _configuration.AddMapsFromAssembly(assembly, namespaces); + return this; + } + + /// + /// Finds exported entity map types in the assembly containing + /// and adds them to the configuration. + /// + /// A marker type from the assembly to scan. + /// Optional namespaces used to filter discovered entity map types. + /// The current builder. + [RequiresUnreferencedCode(AssemblyScanningRequiresUnreferencedCodeMessage)] + public FluentMapConfigurationBuilder AddMapsFromAssemblyContaining(params string[] namespaces) + { + EnsureNotBuilt(); + _configuration.AddMapsFromAssemblyContaining(namespaces); + return this; + } + + /// + /// Adds the specified convention to the configuration. + /// + /// The convention type. + /// A convention configuration object that writes to this builder. + public FluentConventionConfiguration AddConvention() + where TConvention : Convention, new() + { + EnsureNotBuilt(); + return _configuration.AddConvention(); + } + + /// + /// Adds a naming policy to the configuration. + /// + /// The naming policy used to transform member names into column names. + /// A value indicating whether generated column mappings are case sensitive. + /// A convention configuration object that writes to this builder. + public FluentConventionConfiguration UseNamingPolicy(NamingPolicy namingPolicy, bool caseSensitive = true) + { + EnsureNotBuilt(); + return _configuration.UseNamingPolicy(namingPolicy, caseSensitive); + } + + /// + /// Adds a custom naming policy to the configuration. + /// + /// A function that receives a member name and returns a column name. + /// A value indicating whether generated column mappings are case sensitive. + /// A convention configuration object that writes to this builder. + public FluentConventionConfiguration UseNamingPolicy(Func transformer, bool caseSensitive = true) + { + EnsureNotBuilt(); + return _configuration.UseNamingPolicy(transformer, caseSensitive); + } + + /// + /// Validates the current mutable configuration using the same runtime validator as . + /// + public void Validate() + { + _registry.ValidateConfiguration(); + } + + /// + /// Validates the mutable registrations and returns an immutable configuration snapshot. + /// + /// The immutable FluentMap configuration. + public ImmutableFluentMapConfiguration Build() + { + if (_builtConfiguration != null) + { + return _builtConfiguration; + } + + _registry.ValidateConfiguration(); + _builtConfiguration = ImmutableFluentMapConfiguration.Create(_registry); + return _builtConfiguration; + } + + private void EnsureNotBuilt() + { + if (_builtConfiguration != null) + { + throw new InvalidOperationException("The FluentMap configuration builder cannot be mutated after Build() has been called."); + } + } + } +} diff --git a/src/Dapper.FluentMap/Configuration/ImmutableFluentMapConfiguration.cs b/src/Dapper.FluentMap/Configuration/ImmutableFluentMapConfiguration.cs new file mode 100644 index 0000000..3dc88b2 --- /dev/null +++ b/src/Dapper.FluentMap/Configuration/ImmutableFluentMapConfiguration.cs @@ -0,0 +1,409 @@ +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.Data; +using System.Linq; +using System.Reflection; +using Dapper.FluentMap.Conventions; +using Dapper.FluentMap.Mapping; +using Dapper.FluentMap.Materialization; + +namespace Dapper.FluentMap.Configuration +{ + /// + /// Represents a read-only snapshot produced by . + /// + /// + /// The snapshot is safe to share between threads. It does not expose the mutable map or convention + /// collections used while the builder was being configured. + /// + public sealed class ImmutableFluentMapConfiguration + { + private ImmutableFluentMapConfiguration( + IReadOnlyDictionary entityMaps, + IReadOnlyList profileMaps, + IReadOnlyDictionary> typeConventions, + IReadOnlyList generatedMaterializers) + { + EntityMaps = entityMaps; + ProfileMaps = profileMaps; + TypeConventions = typeConventions; + GeneratedMaterializers = generatedMaterializers; + } + + /// + /// Gets the configured default entity maps by entity type. + /// + public IReadOnlyDictionary EntityMaps { get; } + + /// + /// Gets the configured mapping profiles. + /// + public IReadOnlyList ProfileMaps { get; } + + /// + /// Gets the configured conventions and naming policies by entity type. + /// + public IReadOnlyDictionary> TypeConventions { get; } + + /// + /// Gets the generated materializer registrations captured by this configuration. + /// + public IReadOnlyList GeneratedMaterializers { get; } + + internal static ImmutableFluentMapConfiguration Create(MappingRegistry registry) + { + if (registry == null) + { + throw new ArgumentNullException(nameof(registry)); + } + + var entityMaps = registry.EntityMaps + .OrderBy(map => map.Key.FullName, StringComparer.Ordinal) + .ToDictionary( + map => map.Key, + map => EntityMappingConfiguration.Create(map.Key, map.Value)); + + var profileMaps = registry.ProfileMaps + .OrderBy(map => map.Key.EntityType.FullName, StringComparer.Ordinal) + .ThenBy(map => map.Key.ProfileType.FullName, StringComparer.Ordinal) + .Select(map => ProfileMappingConfiguration.Create(map.Key.EntityType, map.Key.ProfileType, map.Value)) + .ToList(); + + var conventions = registry.TypeConventions + .OrderBy(map => map.Key.FullName, StringComparer.Ordinal) + .ToDictionary( + map => map.Key, + map => (IReadOnlyList)new ReadOnlyCollection( + map.Value + .Select(convention => ConventionMappingConfiguration.Create(map.Key, convention)) + .ToList())); + + var materializers = registry.GetGeneratedMaterializerSnapshots() + .Select(GeneratedMaterializerConfiguration.Create) + .ToList(); + + return new ImmutableFluentMapConfiguration( + new ReadOnlyDictionary(entityMaps), + new ReadOnlyCollection(profileMaps), + new ReadOnlyDictionary>(conventions), + new ReadOnlyCollection(materializers)); + } + } + + /// + /// Describes an entity map captured in an immutable FluentMap configuration. + /// + public sealed class EntityMappingConfiguration + { + private EntityMappingConfiguration( + Type entityType, + Type mapType, + IReadOnlyList propertyMaps, + IReadOnlyList includedBaseTypes) + { + EntityType = entityType; + MapType = mapType; + PropertyMaps = propertyMaps; + IncludedBaseTypes = includedBaseTypes; + } + + /// + /// Gets the mapped entity type. + /// + public Type EntityType { get; } + + /// + /// Gets the concrete map type that produced this configuration. + /// + public Type MapType { get; } + + /// + /// Gets the explicit property maps captured from the entity map. + /// + public IReadOnlyList PropertyMaps { get; } + + /// + /// Gets the base entity types explicitly included by this map. + /// + public IReadOnlyList IncludedBaseTypes { get; } + + internal static EntityMappingConfiguration Create(Type entityType, IEntityMap map) + { + if (entityType == null) + { + throw new ArgumentNullException(nameof(entityType)); + } + + if (map == null) + { + throw new ArgumentNullException(nameof(map)); + } + + var includedBaseTypes = map as IEntityMapWithIncludedBaseTypes; + return new EntityMappingConfiguration( + entityType, + map.GetType(), + new ReadOnlyCollection( + map.PropertyMaps.Select(PropertyMappingConfiguration.Create).ToList()), + new ReadOnlyCollection( + includedBaseTypes == null ? new List() : includedBaseTypes.IncludedBaseTypes.ToList())); + } + } + + /// + /// Describes a profile map captured in an immutable FluentMap configuration. + /// + public sealed class ProfileMappingConfiguration + { + private ProfileMappingConfiguration( + Type entityType, + Type profileType, + Type mapType, + IReadOnlyList propertyMaps, + IReadOnlyList includedBaseTypes) + { + EntityType = entityType; + ProfileType = profileType; + MapType = mapType; + PropertyMaps = propertyMaps; + IncludedBaseTypes = includedBaseTypes; + } + + /// + /// Gets the mapped entity type. + /// + public Type EntityType { get; } + + /// + /// Gets the selected mapping profile type. + /// + public Type ProfileType { get; } + + /// + /// Gets the concrete map type that produced this profile configuration. + /// + public Type MapType { get; } + + /// + /// Gets the explicit property maps captured from the profile map. + /// + public IReadOnlyList PropertyMaps { get; } + + /// + /// Gets the base entity types explicitly included by this profile map. + /// + public IReadOnlyList IncludedBaseTypes { get; } + + internal static ProfileMappingConfiguration Create(Type entityType, Type profileType, IEntityMap map) + { + var entityMap = EntityMappingConfiguration.Create(entityType, map); + return new ProfileMappingConfiguration( + entityType, + profileType, + entityMap.MapType, + entityMap.PropertyMaps, + entityMap.IncludedBaseTypes); + } + } + + /// + /// Describes a convention or naming policy captured in an immutable FluentMap configuration. + /// + public sealed class ConventionMappingConfiguration + { + private ConventionMappingConfiguration( + Type entityType, + Type conventionType, + IReadOnlyList propertyMaps) + { + EntityType = entityType; + ConventionType = conventionType; + PropertyMaps = propertyMaps; + } + + /// + /// Gets the entity type to which the convention was applied. + /// + public Type EntityType { get; } + + /// + /// Gets the concrete convention type. + /// + public Type ConventionType { get; } + + /// + /// Gets the property maps generated by the convention for the entity. + /// + public IReadOnlyList PropertyMaps { get; } + + internal static ConventionMappingConfiguration Create(Type entityType, Convention convention) + { + if (convention == null) + { + throw new ArgumentNullException(nameof(convention)); + } + + return new ConventionMappingConfiguration( + entityType, + convention.GetType(), + new ReadOnlyCollection( + convention.PropertyMaps + .Where(map => IsMapForEntity(entityType, map)) + .Select(PropertyMappingConfiguration.Create) + .ToList())); + } + + private static bool IsMapForEntity(Type type, IPropertyMap map) + { +#if NETSTANDARD1_3 + return map.PropertyInfo.DeclaringType == type; +#else + return map.PropertyInfo.ReflectedType == type; +#endif + } + } + + /// + /// Describes a property map captured in an immutable FluentMap configuration. + /// + public sealed class PropertyMappingConfiguration + { + private PropertyMappingConfiguration( + string memberPath, + PropertyInfo propertyInfo, + string columnName, + bool caseSensitive, + bool ignored, + PropertyPersistenceMetadata persistence, + PropertyConversionMetadata conversion) + { + MemberPath = memberPath; + PropertyInfo = propertyInfo; + ColumnName = columnName; + CaseSensitive = caseSensitive; + Ignored = ignored; + Persistence = persistence; + Conversion = conversion; + } + + /// + /// Gets the mapped member path. + /// + public string MemberPath { get; } + + /// + /// Gets the terminal property metadata for the mapped member path. + /// + public PropertyInfo PropertyInfo { get; } + + /// + /// Gets the configured column name. + /// + public string ColumnName { get; } + + /// + /// Gets a value indicating whether column matching is case sensitive. + /// + public bool CaseSensitive { get; } + + /// + /// Gets a value indicating whether FluentMap ignores this property. + /// + public bool Ignored { get; } + + /// + /// Gets the persistence metadata captured for this property. + /// + public PropertyPersistenceMetadata Persistence { get; } + + /// + /// Gets the conversion metadata captured for this property. + /// + public PropertyConversionMetadata Conversion { get; } + + internal static PropertyMappingConfiguration Create(IPropertyMap map) + { + if (map == null) + { + throw new ArgumentNullException(nameof(map)); + } + + return new PropertyMappingConfiguration( + PropertyMapIdentity.GetMemberPath(map).ToString(), + map.PropertyInfo, + map.ColumnName, + map.CaseSensitive, + map.Ignored, + PropertyMapPersistence.GetPersistence(map), + PropertyMapConversion.GetConversion(map)); + } + } + + /// + /// Describes a generated materializer captured in an immutable FluentMap configuration. + /// + public sealed class GeneratedMaterializerConfiguration + { + private GeneratedMaterializerConfiguration( + Type entityType, + Type profileType, + IReadOnlyList columns, + Func materializer) + { + EntityType = entityType; + ProfileType = profileType; + Columns = columns; + Materializer = materializer; + } + + /// + /// Gets the entity type produced by the generated materializer. + /// + public Type EntityType { get; } + + /// + /// Gets the mapping profile type, or for the default map. + /// + public Type ProfileType { get; } + + /// + /// Gets the ordered column shape expected by the generated materializer. + /// + public IReadOnlyList Columns { get; } + + internal Func Materializer { get; } + + internal static GeneratedMaterializerConfiguration Create(GeneratedMaterializerRegistrationSnapshot snapshot) + { + return new GeneratedMaterializerConfiguration( + snapshot.EntityType, + snapshot.ProfileType, + new ReadOnlyCollection(snapshot.Columns.ToList()), + snapshot.Materializer); + } + } + + internal sealed class GeneratedMaterializerRegistrationSnapshot + { + internal GeneratedMaterializerRegistrationSnapshot( + Type entityType, + Type profileType, + IReadOnlyList columns, + Func materializer) + { + EntityType = entityType; + ProfileType = profileType; + Columns = columns; + Materializer = materializer; + } + + internal Type EntityType { get; } + + internal Type ProfileType { get; } + + internal IReadOnlyList Columns { get; } + + internal Func Materializer { get; } + } +} diff --git a/src/Dapper.FluentMap/MappingRegistry.cs b/src/Dapper.FluentMap/MappingRegistry.cs index d60b48a..e3c4a01 100644 --- a/src/Dapper.FluentMap/MappingRegistry.cs +++ b/src/Dapper.FluentMap/MappingRegistry.cs @@ -7,6 +7,7 @@ using System.Linq; using System.Reflection; using System.Text; +using Dapper.FluentMap.Configuration; using Dapper.FluentMap.Conventions; using Dapper.FluentMap.Diagnostics; using Dapper.FluentMap.Mapping; @@ -17,6 +18,8 @@ namespace Dapper.FluentMap { internal sealed class MappingRegistry { + private readonly bool _installDapperTypeMaps; + private readonly ConcurrentDictionary _propertyMapCache = new ConcurrentDictionary(); @@ -35,6 +38,11 @@ internal sealed class MappingRegistry internal ConcurrentDictionary> TypeConventions { get; } = new ConcurrentDictionary>(); + internal MappingRegistry(bool installDapperTypeMaps = true) + { + _installDapperTypeMaps = installDapperTypeMaps; + } + internal int CacheEntryCount => _propertyMapCache.Count; internal int MaterializationPlanCacheEntryCount => _materializationPlanCache.Count; @@ -61,6 +69,20 @@ internal IReadOnlyDictionary> GetTypeConventions return new ReadOnlyDictionary>(snapshot); } + internal IReadOnlyList GetGeneratedMaterializerSnapshots() + { + return _generatedMaterializers + .OrderBy(materializer => materializer.Key.Type.FullName, StringComparer.Ordinal) + .ThenBy(materializer => materializer.Key.ProfileType == null ? string.Empty : materializer.Key.ProfileType.FullName, StringComparer.Ordinal) + .ThenBy(materializer => string.Join("|", materializer.Key.ColumnNames), StringComparer.Ordinal) + .Select(materializer => new GeneratedMaterializerRegistrationSnapshot( + materializer.Key.Type, + materializer.Key.ProfileType, + materializer.Value.Columns, + materializer.Value.Materialize)) + .ToList(); + } + internal void AddEntityMap(IEntityMap mapper) where TEntity : class { @@ -471,6 +493,11 @@ internal void Reset(params Type[] dapperTypes) private void SetDapperTypeMap(Type type) { + if (!_installDapperTypeMaps) + { + return; + } + var instance = new FluentMapTypeMap(type); SqlMapper.SetTypeMap(type, instance); } diff --git a/test/Dapper.FluentMap.Tests/ImmutableConfigurationModelTests.cs b/test/Dapper.FluentMap.Tests/ImmutableConfigurationModelTests.cs new file mode 100644 index 0000000..9b8138b --- /dev/null +++ b/test/Dapper.FluentMap.Tests/ImmutableConfigurationModelTests.cs @@ -0,0 +1,473 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Dapper.FluentMap.Configuration; +using Dapper.FluentMap.Conventions; +using Dapper.FluentMap.Mapping; +using Dapper.FluentMap.Materialization; +using Dapper.FluentMap.Naming; +using Xunit; + +namespace Dapper.FluentMap.Tests +{ + public class ImmutableConfigurationModelTests + { + [Fact] + public void BuildShouldCreateEmptyImmutableConfiguration() + { + var builder = new FluentMapConfigurationBuilder(); + + var configuration = builder.Build(); + + Assert.Empty(configuration.EntityMaps); + Assert.Empty(configuration.ProfileMaps); + Assert.Empty(configuration.TypeConventions); + Assert.Empty(configuration.GeneratedMaterializers); + Assert.Same(configuration, builder.Build()); + } + + [Fact] + public void BuildShouldCaptureSingleMapMetadata() + { + var builder = new FluentMapConfigurationBuilder(); + + var configuration = builder + .AddMap() + .Build(); + + var entityMap = Assert.Single(configuration.EntityMaps).Value; + var propertyMap = Assert.Single(entityMap.PropertyMaps); + + Assert.Equal(typeof(SingleSnapshotEntity), entityMap.EntityType); + Assert.Equal(typeof(SingleSnapshotMap), entityMap.MapType); + Assert.Equal(nameof(SingleSnapshotEntity.Id), propertyMap.MemberPath); + Assert.Equal("single_id", propertyMap.ColumnName); + Assert.False(propertyMap.CaseSensitive); + Assert.True(propertyMap.Persistence.HasDatabaseDefaultOnInsert); + } + + [Fact] + public void BuildShouldCaptureMultipleIndependentMaps() + { + var configuration = new FluentMapConfigurationBuilder() + .AddMap() + .AddMap() + .Build(); + + Assert.Equal(2, configuration.EntityMaps.Count); + Assert.Equal("first_id", configuration.EntityMaps[typeof(FirstSnapshotEntity)].PropertyMaps[0].ColumnName); + Assert.Equal("second_name", configuration.EntityMaps[typeof(SecondSnapshotEntity)].PropertyMaps[0].ColumnName); + } + + [Fact] + public void BuildShouldCaptureConventionMetadata() + { + var builder = new FluentMapConfigurationBuilder(); + + builder.AddConvention().ForEntity(); + var configuration = builder.Build(); + + var convention = Assert.Single(configuration.TypeConventions[typeof(ConventionSnapshotEntity)]); + var propertyMap = Assert.Single(convention.PropertyMaps); + + Assert.Equal(typeof(SnapshotPrefixConvention), convention.ConventionType); + Assert.Equal("cfgId", propertyMap.ColumnName); + } + + [Fact] + public void BuildShouldCaptureNamingPolicyMetadata() + { + var builder = new FluentMapConfigurationBuilder(); + + builder.UseNamingPolicy(NamingPolicy.SnakeCase, caseSensitive: false) + .ForEntity(); + var configuration = builder.Build(); + + var convention = Assert.Single(configuration.TypeConventions[typeof(NamingSnapshotEntity)]); + var propertyMap = Assert.Single(convention.PropertyMaps); + + Assert.Equal("first_name", propertyMap.ColumnName); + Assert.False(propertyMap.CaseSensitive); + } + + [Fact] + public void BuildShouldCaptureIncludedBaseMappings() + { + var configuration = new FluentMapConfigurationBuilder() + .AddMap() + .AddMap() + .Build(); + + var derivedMap = configuration.EntityMaps[typeof(DerivedSnapshotEntity)]; + + Assert.Equal(typeof(BaseSnapshotEntity), Assert.Single(derivedMap.IncludedBaseTypes)); + Assert.Equal("derived_name", Assert.Single(derivedMap.PropertyMaps).ColumnName); + } + + [Fact] + public void BuildShouldCaptureProfileMetadata() + { + var configuration = new FluentMapConfigurationBuilder() + .AddMap() + .AddProfile() + .Build(); + + var profile = Assert.Single(configuration.ProfileMaps); + var propertyMap = Assert.Single(profile.PropertyMaps); + + Assert.Equal(typeof(ProfileSnapshotEntity), profile.EntityType); + Assert.Equal(typeof(AlternateSnapshotProfile), profile.ProfileType); + Assert.Equal("legacy_id", propertyMap.ColumnName); + } + + [Fact] + public void BuildShouldCaptureConverterMetadata() + { + var configuration = new FluentMapConfigurationBuilder() + .AddMap() + .Build(); + + var propertyMap = Assert.Single(configuration.EntityMaps[typeof(ConversionSnapshotEntity)].PropertyMaps); + + Assert.True(propertyMap.Conversion.HasReadConverter); + Assert.Equal(typeof(StatusReadConverter), propertyMap.Conversion.ReadConverter.ConverterType); + Assert.Equal(typeof(string), propertyMap.Conversion.ReadConverter.DatabaseType); + } + + [Fact] + public void BuildShouldCaptureGeneratedMaterializerRegistrations() + { + var configuration = new FluentMapConfigurationBuilder() + .AddMap() + .AddGeneratedMaterializer( + new[] { GeneratedMaterializerColumn.Map("generated_id", nameof(GeneratedSnapshotEntity.Id)) }, + record => new GeneratedSnapshotEntity { Id = Convert.ToInt32(record.GetValue(0)) }) + .Build(); + + var materializer = Assert.Single(configuration.GeneratedMaterializers); + var column = Assert.Single(materializer.Columns); + + Assert.Equal(typeof(GeneratedSnapshotEntity), materializer.EntityType); + Assert.Null(materializer.ProfileType); + Assert.Equal("generated_id", column.ColumnName); + Assert.Equal(nameof(GeneratedSnapshotEntity.Id), column.MemberPath); + } + + [Fact] + public void ConfigureShouldReuseExistingConfigurationDslAgainstBuilderState() + { + var configuration = new FluentMapConfigurationBuilder() + .Configure(config => config.AddMap()) + .Build(); + + Assert.True(configuration.EntityMaps.ContainsKey(typeof(SingleSnapshotEntity))); + Assert.False(FluentMapper.EntityMaps.ContainsKey(typeof(SingleSnapshotEntity))); + } + + [Fact] + public void BuildShouldRejectDuplicateMaps() + { + var builder = new FluentMapConfigurationBuilder() + .AddMap(); + + var exception = Assert.Throws(() => builder.AddMap()); + + Assert.Contains("already has a configured entity map", exception.Message); + } + + [Fact] + public void BuildShouldReuseRuntimeValidationForInvalidMutatedMap() + { + var map = new InvalidAfterRegistrationMap(); + var builder = new FluentMapConfigurationBuilder() + .AddMap(map); + + map.PropertyMaps.Add(null); + + var exception = Assert.Throws(() => builder.Build()); + + Assert.Contains("configuration validation found", exception.Message); + } + + [Fact] + public void BuildShouldFreezeBuilderMutationBoundary() + { + var builder = new FluentMapConfigurationBuilder(); + var convention = builder.AddConvention(); + + builder.Build(); + + Assert.Throws(() => builder.AddMap()); + Assert.Throws(() => builder.Configure(configuration => configuration.AddMap())); + Assert.Throws(() => convention.ForEntity()); + } + + [Fact] + public void BuildShouldNotExposeMutableEffectiveCollections() + { + var configuration = new FluentMapConfigurationBuilder() + .AddMap() + .Build(); + + var mutableMaps = Assert.IsAssignableFrom>(configuration.EntityMaps); + var mutablePropertyMaps = Assert.IsAssignableFrom>( + configuration.EntityMaps[typeof(SingleSnapshotEntity)].PropertyMaps); + + Assert.Throws(() => mutableMaps.Add(typeof(SecondSnapshotEntity), null)); + Assert.Throws(() => mutablePropertyMaps.Add(null)); + } + + [Fact] + public void BuildShouldCaptureSnapshotIndependentFromLaterMapMutation() + { + var map = new MutableSnapshotMap(); + var builder = new FluentMapConfigurationBuilder() + .AddMap(map); + + var configuration = builder.Build(); + + map.PropertyMaps.Clear(); + + var propertyMap = Assert.Single(configuration.EntityMaps[typeof(MutableSnapshotEntity)].PropertyMaps); + Assert.Equal("before_build", propertyMap.ColumnName); + } + + [Fact] + public void BuildersShouldProduceIndependentConfigurationsForSameEntityType() + { + var first = new FluentMapConfigurationBuilder() + .AddMap(new FirstIndependentMap()) + .Build(); + var second = new FluentMapConfigurationBuilder() + .AddMap(new SecondIndependentMap()) + .Build(); + + Assert.Equal("first_id", first.EntityMaps[typeof(IndependentSnapshotEntity)].PropertyMaps[0].ColumnName); + Assert.Equal("second_id", second.EntityMaps[typeof(IndependentSnapshotEntity)].PropertyMaps[0].ColumnName); + } + + [Fact] + public void ImmutableConfigurationShouldSupportConcurrentReads() + { + var configuration = new FluentMapConfigurationBuilder() + .AddMap() + .Build(); + var columnNames = new string[100]; + + Parallel.For(0, columnNames.Length, index => + { + columnNames[index] = configuration.EntityMaps[typeof(SingleSnapshotEntity)] + .PropertyMaps[0] + .ColumnName; + }); + + Assert.True(columnNames.All(column => column == "single_id")); + } + + private sealed class SingleSnapshotEntity + { + public int Id { get; set; } + } + + private sealed class SingleSnapshotMap : EntityMap + { + public SingleSnapshotMap() + { + Map(entity => entity.Id).ToColumn("single_id", caseSensitive: false).DatabaseDefaultOnInsert(); + } + } + + private sealed class DuplicateSingleSnapshotMap : EntityMap + { + public DuplicateSingleSnapshotMap() + { + Map(entity => entity.Id).ToColumn("duplicate_id"); + } + } + + private sealed class FirstSnapshotEntity + { + public int Id { get; set; } + } + + private sealed class FirstSnapshotMap : EntityMap + { + public FirstSnapshotMap() + { + Map(entity => entity.Id).ToColumn("first_id"); + } + } + + private sealed class SecondSnapshotEntity + { + public string Name { get; set; } + } + + private sealed class SecondSnapshotMap : EntityMap + { + public SecondSnapshotMap() + { + Map(entity => entity.Name).ToColumn("second_name"); + } + } + + private sealed class SnapshotPrefixConvention : Convention + { + public SnapshotPrefixConvention() + { + Properties().Configure(configuration => configuration.HasPrefix("cfg")); + } + } + + private sealed class ConventionSnapshotEntity + { + public int Id { get; set; } + } + + private sealed class NamingSnapshotEntity + { + public string FirstName { get; set; } + } + + private class BaseSnapshotEntity + { + public int Id { get; set; } + } + + private sealed class DerivedSnapshotEntity : BaseSnapshotEntity + { + public string Name { get; set; } + } + + private sealed class BaseSnapshotMap : EntityMap + { + public BaseSnapshotMap() + { + Map(entity => entity.Id).ToColumn("base_id"); + } + } + + private sealed class DerivedSnapshotMap : EntityMap + { + public DerivedSnapshotMap() + { + IncludeBase(); + Map(entity => entity.Name).ToColumn("derived_name"); + } + } + + private sealed class AlternateSnapshotProfile : IMappingProfile + { + } + + private sealed class ProfileSnapshotEntity + { + public int Id { get; set; } + } + + private sealed class ProfileDefaultMap : EntityMap + { + public ProfileDefaultMap() + { + Map(entity => entity.Id).ToColumn("current_id"); + } + } + + private sealed class ProfileAlternateMap : + EntityMap, + IProfileMap + { + public ProfileAlternateMap() + { + Map(entity => entity.Id).ToColumn("legacy_id"); + } + } + + private enum SnapshotStatus + { + Active + } + + private sealed class ConversionSnapshotEntity + { + public SnapshotStatus Status { get; set; } + } + + private sealed class ConversionSnapshotMap : EntityMap + { + public ConversionSnapshotMap() + { + Map(entity => entity.Status).ToColumn("status").ConvertFromDatabaseUsing(); + } + } + + private sealed class StatusReadConverter : IReadPropertyConverter + { + public SnapshotStatus ConvertFromDatabase(string value) + { + return SnapshotStatus.Active; + } + } + + private sealed class GeneratedSnapshotEntity + { + public int Id { get; set; } + } + + private sealed class GeneratedSnapshotMap : EntityMap + { + public GeneratedSnapshotMap() + { + Map(entity => entity.Id).ToColumn("generated_id"); + } + } + + private sealed class InvalidAfterRegistrationEntity + { + public int Id { get; set; } + } + + private sealed class InvalidAfterRegistrationMap : EntityMap + { + public InvalidAfterRegistrationMap() + { + Map(entity => entity.Id).ToColumn("invalid_id"); + } + } + + private sealed class MutableSnapshotEntity + { + public int Id { get; set; } + } + + private sealed class MutableSnapshotMap : EntityMap + { + public MutableSnapshotMap() + { + Map(entity => entity.Id).ToColumn("before_build"); + } + } + + private sealed class IndependentSnapshotEntity + { + public int Id { get; set; } + } + + private sealed class FirstIndependentMap : EntityMap + { + public FirstIndependentMap() + { + Map(entity => entity.Id).ToColumn("first_id"); + } + } + + private sealed class SecondIndependentMap : EntityMap + { + public SecondIndependentMap() + { + Map(entity => entity.Id).ToColumn("second_id"); + } + } + } +} From 11028d546e476389ac9574ecaab1e53e599a71e7 Mon Sep 17 00:00:00 2001 From: Rodrigo de Oliveira Date: Wed, 29 Jul 2026 08:12:38 -0300 Subject: [PATCH 31/49] refactor(runtime): isolate mapping configuration state --- .sdd/etapa-11/04-isolated-runtime.md | 209 +++++++++ .sdd/etapa-11/05-performance-impact.md | 92 ++++ .sdd/etapa-11/STATUS.md | 51 +- README.md | 56 ++- .../Dapper.FluentMap.Benchmarks/Program.cs | 57 +++ .../ImmutableFluentMapConfiguration.cs | 8 +- .../RuntimeConfigurationRegistryFactory.cs | 177 +++++++ src/Dapper.FluentMap/FluentMapRuntime.cs | 307 ++++++++++++ src/Dapper.FluentMap/FluentMapper.cs | 9 +- src/Dapper.FluentMap/MappedGridReader.cs | 9 +- src/Dapper.FluentMap/MappingRegistry.cs | 76 ++- .../Materialization/MappedRowMaterializer.cs | 17 +- src/Dapper.FluentMap/QueryMappedExtensions.cs | 65 ++- .../IsolatedRuntimeTests.cs | 439 ++++++++++++++++++ 14 files changed, 1522 insertions(+), 50 deletions(-) create mode 100644 .sdd/etapa-11/04-isolated-runtime.md create mode 100644 .sdd/etapa-11/05-performance-impact.md create mode 100644 src/Dapper.FluentMap/Configuration/RuntimeConfigurationRegistryFactory.cs create mode 100644 src/Dapper.FluentMap/FluentMapRuntime.cs create mode 100644 test/Dapper.FluentMap.Tests/IsolatedRuntimeTests.cs diff --git a/.sdd/etapa-11/04-isolated-runtime.md b/.sdd/etapa-11/04-isolated-runtime.md new file mode 100644 index 0000000..af6ba05 --- /dev/null +++ b/.sdd/etapa-11/04-isolated-runtime.md @@ -0,0 +1,209 @@ +# Isolated FluentMap Runtime + +## Objetivo + +Introduzir o runtime associado a uma configuracao especifica: + +```text +ImmutableFluentMapConfiguration + -> FluentMapRuntime + -> materialization / diagnostics / query integration +``` + +O nome concreto segue a ADR-3 e a ADR-13: `FluentMapRuntime` e o runtime +publico; `ImmutableFluentMapConfiguration` continua sendo o snapshot produzido +por `FluentMapConfigurationBuilder`. + +## Responsibilities + +`FluentMapRuntime` e responsavel por: + +- manter uma referencia para a configuracao imutavel; +- possuir caches derivados daquela configuracao; +- resolver maps explicitos, profiles, conventions e fallback default do Dapper; +- selecionar materializers gerados quando o shape e o mapping efetivo batem; +- criar planos de materializacao de runtime quando nao ha generated match; +- executar diagnostics `Validate()` e `Explain()` sem acessar estado global; +- alimentar `QueryMapped`, `ReadMapped`, unbuffered sync e async streaming. + +O runtime nao possui conexao, transacao, comando, SQL nem reader. Esses recursos +continuam pertencendo ao caller e aos helpers de query. + +## Configuration Ownership + +O ownership e: + +```text +FluentMapConfigurationBuilder mutavel + -> Build() +ImmutableFluentMapConfiguration imutavel + -> new FluentMapRuntime(configuration) +``` + +O runtime publico e criado a partir do snapshot, nao a partir das instancias +mutaveis de `EntityMap`, `PropertyMap` ou `Convention` usadas no builder. + +Para isso, `RuntimeConfigurationRegistryFactory` reconstrui um registry interno +com adaptadores de snapshot: + +- entity/profile maps usam property maps de snapshot; +- member paths completos sao preservados internamente; +- persistence e converter metadata sao preservados; +- generated materializer delegates sao registrados no registry do runtime; +- convention maps sao reconstruidos como maps efetivos de convention. + +Mutacoes tardias no builder ou nos maps originais nao alteram runtime ja criado. + +## Caches + +Os caches derivados agora sao runtime-scoped porque vivem no +`MappingRegistry` possuido por cada `FluentMapRuntime`: + +- property map cache; +- profile property map cache; +- convention lookup cache; +- materialization plan cache; +- generated materializer lookup/index. + +A camada estatica `FluentMapper` preserva um `MappingRegistry` global por +compatibilidade, mas tambem o envolve em um `FluentMapRuntime` default. Assim, +os entry points estaticos delegam para a mesma abstracao usada por runtimes +isolados. + +## Cache Keys + +As chaves de cache continuam contendo: + +- entity type; +- profile type, quando aplicavel; +- column name ou ordered column shape; +- estrategia de lookup. + +Elas nao incluem um id explicito de configuracao porque cada runtime possui seu +proprio conjunto de caches. Portanto: + +```text +Runtime A cache: Type + Profile + Shape +Runtime B cache: Type + Profile + Shape +``` + +Mesmo que `Type`, `Profile` e `Shape` sejam iguais, os resultados nao colidem +porque as entradas estao em instancias diferentes. + +## Materializers + +`MappedRowMaterializer` nao consulta mais `FluentMapper.Registry` diretamente. +Ele recebe um `FluentMapRuntime` e faz: + +```text +runtime.Registry.TryGetGeneratedMaterializer(...) + -> generated delegate +runtime.Registry.GetMaterializationPlan(...) + -> runtime fallback plan +``` + +O plano fallback ainda e cacheado por runtime e nao por linha. A indirecao de +runtime acontece ao criar o materializer para o reader; a materializacao linha a +linha executa o delegate/plano ja resolvido. + +## Profiles + +Profiles continuam query-scoped por `TProfile`. + +No runtime isolado: + +- profile maps pertencem ao snapshot da configuracao; +- o cache de profile lookup fica no runtime; +- o mesmo tipo de profile pode existir em duas configuracoes diferentes com + mappings diferentes; +- default maps nao vazam para profiles, preservando o comportamento existente. + +## Converters + +Property converter metadata e copiada para o snapshot e reconstruida no runtime. +Converters por instancia/delegate continuam sendo reutilizados pelo runtime, com +o mesmo contrato de thread safety ja documentado. + +O runtime nao introduz factory/DI de converter. Isso permanece item futuro para +nao misturar isolamento de configuracao com ciclo de vida externo. + +## Generated Registrations + +Generated materializers ficam associados a configuracao que os registrou. + +O codigo gerado continua podendo registrar descriptors pelo builder via: + +```csharp +new FluentMapConfigurationBuilder() + .Configure(config => config.AddGeneratedMappings()) + .Build(); +``` + +O delegate gerado pode ser estruturalmente global por tipo no assembly +consumidor, desde que ele seja tratado como factory/metadata reutilizavel. Ele +nao deve possuir mapping state runtime. O mapping state efetivo fica no runtime +que validou o descriptor contra sua propria configuracao. + +## Diagnostics + +`FluentMapRuntime` expoe: + +```csharp +runtime.Validate(); +runtime.Explain(); +runtime.Explain(); +``` + +Essas chamadas usam o registry do runtime. `FluentMapper.Validate()` e +`FluentMapper.Explain()` continuam existindo e delegam ao runtime global de +compatibilidade. + +## Query Integration + +O caminho estatico existente usa `FluentMapper.Runtime`. + +O runtime isolado tambem oferece entry points de instancia para: + +- `QueryMapped()`; +- `QueryMapped()`; +- `QueryMappedSingle()`; +- `QueryMappedSingle()`; +- `QueryMappedUnbuffered()`; +- `QueryMappedUnbuffered()`; +- `QueryMappedUnbufferedAsync()`; +- `QueryMultipleMapped(...)`, cujo `MappedGridReader` carrega o runtime. + +`MappedGridReader` agora possui um runtime. `ReadMapped()` e +`ReadMapped()` usam o runtime carregado pelo reader. + +## Thread Safety + +Modelo efetivo: + +- builder: mutavel, uso de startup, nao thread-safe; +- immutable configuration: read-only e compartilhavel; +- runtime: thread-safe para consultas concorrentes; +- caches: `ConcurrentDictionary`; +- materialization delegates/plans: criados uma vez por shape e reutilizados; +- converters: podem ser chamados concorrentemente e devem ser stateless ou + thread-safe. + +O runtime nao possui dispose porque nao possui recursos descartaveis. + +## Lifetime + +Lifetime recomendado: + +- `ImmutableFluentMapConfiguration`: singleton; +- `FluentMapRuntime`: singleton; +- wrappers de aplicacao: scoped/transient somente se carregarem recursos scoped + que nao pertencem ao FluentMap. + +## Limites + +`Dapper.Query()` puro continua dependendo de `SqlMapper.SetTypeMap`, que e +process-wide por tipo. Multiplas configuracoes simultaneas devem usar os entry +points controlados pelo `FluentMapRuntime`. + +Dommel continua bridge process-wide nesta etapa. O isolamento completo de Dommel +exige design proprio por causa dos resolvers globais de `DommelMapper`. diff --git a/.sdd/etapa-11/05-performance-impact.md b/.sdd/etapa-11/05-performance-impact.md new file mode 100644 index 0000000..e595dd9 --- /dev/null +++ b/.sdd/etapa-11/05-performance-impact.md @@ -0,0 +1,92 @@ +# Runtime Isolation Performance Impact + +## Mudanca avaliada + +O prompt 11.3 introduziu `FluentMapRuntime` como dono de caches derivados por +configuracao. O hot path de materializacao ficou assim: + +```text +reader shape + -> runtime generated lookup ou runtime plan cache + -> delegate/plano reutilizado por linha +``` + +A indirecao de runtime acontece na criacao do materializer por reader. O lookup +pesado nao e repetido por linha. + +## Caches e custo esperado + +Cada runtime possui seus proprios caches: + +- property map cache; +- materialization plan cache; +- generated materializer lookup. + +Isso aumenta memoria proporcionalmente ao numero de runtimes/configuracoes +ativas, mas elimina colisao entre configuracoes. Para a configuracao usual +singleton, o custo permanece amortizado por runtime. + +## Benchmark smoke + +Comando executado: + +```powershell +dotnet run --project .\benchmarks\Dapper.FluentMap.Benchmarks\Dapper.FluentMap.Benchmarks.csproj --configuration Release -- --filter "*MaterializationSteadyStateBenchmarks*QueryMappedSimple*" --job Dry +``` + +Observacao: por causa do `[ShortRunJob]` ja configurado no benchmark, o comando +executou cenarios `Dry` e `ShortRun`. Estes numeros sao smoke/guardrail, nao +baseline final de release. + +Ambiente reportado pelo BenchmarkDotNet: + +- Windows 11; +- .NET SDK 10.0.302; +- runtime .NET 10.0.10; +- BenchmarkDotNet 0.15.8. + +## Resultados relevantes + +ShortRun, 1000 linhas em SQLite in-memory: + +| Metodo | Mean | Allocated | +| --- | ---: | ---: | +| `QueryMappedSimple` | 1.768 ms | 261.15 KB | +| `RuntimeQueryMappedSimple` | 1.861 ms | 261.16 KB | +| `QueryMappedSimpleUnbuffered` | 1.773 ms | 245.19 KB | +| `RuntimeQueryMappedSimpleUnbuffered` | 1.750 ms | 245.20 KB | +| `QueryMappedSimpleUnbufferedAsync` | 1.796 ms | 245.60 KB | +| `RuntimeQueryMappedSimpleUnbufferedAsync` | 1.928 ms | 245.61 KB | +| `QueryMappedSimpleRuntimeFallback` | 1.536 ms | 361.58 KB | +| `RuntimeQueryMappedSimpleRuntimeFallback` | 1.682 ms | 361.58 KB | + +Dry, uma iteracao cold/smoke: + +| Metodo | Mean | Allocated | +| --- | ---: | ---: | +| `QueryMappedSimple` | 3.030 ms | 362.82 KB | +| `RuntimeQueryMappedSimple` | 2.484 ms | 362.83 KB | +| `QueryMappedSimpleRuntimeFallback` | 2.391 ms | 361.63 KB | +| `RuntimeQueryMappedSimpleRuntimeFallback` | 2.575 ms | 361.63 KB | + +## Leitura + +O smoke nao indica alocacao extra relevante no steady-state. As diferencas de +tempo ficaram dentro de variacao esperada para `ShortRun` curto, e o caminho +runtime isolado manteve o mesmo perfil de alocacao dos helpers estaticos. + +A alteracao importante para performance e estrutural: `FluentMapRuntime` e +resolvido antes da materializacao por linha, e o delegate/plano continua sendo +reutilizado para cada row. + +## Riscos restantes + +Benchmarks completos continuam recomendados antes de release, especialmente: + +- todos os cenarios de `MaterializationSteadyStateBenchmarks`; +- cold start com configuracao grande; +- muitos runtimes ativos simultaneamente; +- generated materializers com converters. + +O custo de memoria por runtime e intencional e deve ser documentado como troca +por isolamento correto entre configuracoes. diff --git a/.sdd/etapa-11/STATUS.md b/.sdd/etapa-11/STATUS.md index 95868a5..add91a5 100644 --- a/.sdd/etapa-11/STATUS.md +++ b/.sdd/etapa-11/STATUS.md @@ -29,20 +29,32 @@ e preparando configuracoes imutaveis com runtime isolado. - `FluentMapConfiguration` e `FluentConventionConfiguration` foram desacopladas do singleton global por registry injetado internamente, preservando os construtores/APIs publicas existentes. - `MappingRegistry` agora pode operar sem instalar type maps globais do Dapper, permitindo builders independentes sem colisao process-wide. - Criados testes de empty configuration, single map, multiple maps, convention, naming, inheritance, profiles, converters, generated registrations, duplicate maps, invalid map, Build, immutability, independent configurations e concurrent reads. +- Criado `04-isolated-runtime.md`. +- Criado `05-performance-impact.md`. +- Implementado `FluentMapRuntime` associado a `ImmutableFluentMapConfiguration`. +- `MappedRowMaterializer` passou a receber runtime e deixou de consultar `FluentMapper.Registry` diretamente. +- `MappedGridReader` passou a carregar o runtime usado por `ReadMapped()` e `ReadMapped()`. +- `FluentMapper` passou a delegar `Validate()` e `Explain()` ao runtime global de compatibilidade. +- Caches de property lookup, generated lookup e materialization plan agora ficam escopados ao registry possuido por cada runtime isolado. +- Generated materializers registrados no builder/snapshot sao reconstruidos por runtime, sem registry global compartilhado. +- Adicionados entry points de instancia no runtime para `QueryMapped`, profile, unbuffered sync, async streaming e `QueryMultipleMapped`. +- Criados testes de runtime isolado para duas configuracoes da mesma entidade, mesmo profile type em configuracoes diferentes, generated materializers, converters, nested mappings, cache isolation, `ReadMapped`, unbuffered, async streaming, diagnostics e concorrencia. +- Benchmarks existentes foram estendidos com cenarios `RuntimeQueryMapped*` comparaveis aos helpers estaticos. +- `README.md` atualizado para documentar `FluentMapRuntime` e os limites restantes de `FluentMapper`, Dapper puro e Dommel. ## Em andamento +- Restore/build/test completos. - Revisao final de diff. -- Commit semantico. +- Commit semantico do prompt 11.3. ## Proximos passos -1. Extrair runtime isolado a partir de `MappingRegistry`. -2. Adaptar `QueryMapped*`/`MappedGridReader` para runtime default e planejar overloads por runtime. -3. Reescrever `FluentMapper` como bridge de compatibilidade, preservando `Initialize` aditivo. -4. Projetar DI em incremento separado. -5. Migrar testes de isolamento/concurrencia para runtime instanciado. -6. Endurecer documentacao e limites de Dommel/Dapper process-wide. +1. Expandir overloads publicos configuration-aware se a API desejada for extension methods em vez de metodos de instancia. +2. Projetar DI em incremento separado. +3. Endurecer Dommel em design proprio, mantendo honestos os limites process-wide de `DommelMapper`. +4. Avaliar full benchmark antes de release. +5. Migrar gradualmente testes antigos de isolamento/concurrencia para runtime instanciado quando isso reduzir dependencia de reset global. ## Decisoes relevantes @@ -52,7 +64,7 @@ e preparando configuracoes imutaveis com runtime isolado. - `ImmutableFluentMapConfiguration` e o snapshot imutavel publico inicial. - `FluentMapConfiguration` permanece a fachada mutavel historica por compatibilidade. - Caches derivados pertencem ao runtime futuro, nao ao estado global. -- `FluentMapper` deve delegar ao runtime default em incremento futuro. +- `FluentMapper` delega ao runtime default para diagnostics e query helpers estaticos. - `Initialize` deve continuar aditivo inicialmente. - `Reset` nao e solucao arquitetural principal. - DI deve registrar configuracao e runtime como singleton. @@ -112,7 +124,7 @@ e preparando configuracoes imutaveis com runtime isolado. - Dommel resolvers/builders sao globais. - Mutacao direta de dicionarios publicos bypassa validacao, invalidacao e instalacao de type map. - Interfaces publicas expõem `IList`, dificultando congelamento sem descritores internos. -- Runtime isolado ainda nao foi extraido; APIs `QueryMapped*` atuais continuam no registry default. +- Runtime isolado foi introduzido para os entry points controlados pelo FluentMap. - Converter instances podem ser reutilizadas concorrentemente. - Generated materializers por instancia/delegate ainda usam fallback runtime. - Native AOT completo continua fora do contrato atual. @@ -128,10 +140,17 @@ e preparando configuracoes imutaveis com runtime isolado. - `README.md` - `src/Dapper.FluentMap/Configuration/FluentMapConfigurationBuilder.cs` - `src/Dapper.FluentMap/Configuration/ImmutableFluentMapConfiguration.cs` +- `src/Dapper.FluentMap/Configuration/RuntimeConfigurationRegistryFactory.cs` +- `src/Dapper.FluentMap/FluentMapRuntime.cs` - `src/Dapper.FluentMap/Configuration/FluentMapConfiguration.cs` - `src/Dapper.FluentMap/Configuration/FluentConventionConfiguration.cs` - `src/Dapper.FluentMap/MappingRegistry.cs` +- `src/Dapper.FluentMap/Materialization/MappedRowMaterializer.cs` +- `src/Dapper.FluentMap/MappedGridReader.cs` +- `src/Dapper.FluentMap/QueryMappedExtensions.cs` - `test/Dapper.FluentMap.Tests/ImmutableConfigurationModelTests.cs` +- `test/Dapper.FluentMap.Tests/IsolatedRuntimeTests.cs` +- `benchmarks/Dapper.FluentMap.Benchmarks/Program.cs` ## Validacao do Prompt 11.1 @@ -149,6 +168,18 @@ e preparando configuracoes imutaveis com runtime isolado. - `dotnet test ./Dapper.FluentMap.sln --configuration Release --no-build`: sucesso, 419 testes aprovados no total. - `dotnet pack`: nao executado; o prompt 11.2 nao alterou empacotamento nem metadata de pacote. +## Validacao do Prompt 11.3 + +- `dotnet build .\src\Dapper.FluentMap\Dapper.FluentMap.csproj --configuration Release`: sucesso, 0 warnings, 0 errors. +- `dotnet build .\test\Dapper.FluentMap.Tests\Dapper.FluentMap.Tests.csproj --configuration Release`: sucesso, 0 warnings, 0 errors. +- `dotnet test .\test\Dapper.FluentMap.Tests\Dapper.FluentMap.Tests.csproj --configuration Release --no-build --filter "FullyQualifiedName~IsolatedRuntimeTests"`: sucesso, 10 testes aprovados. +- `dotnet build .\benchmarks\Dapper.FluentMap.Benchmarks\Dapper.FluentMap.Benchmarks.csproj --configuration Release`: sucesso, 0 warnings, 0 errors. +- `dotnet run --project .\benchmarks\Dapper.FluentMap.Benchmarks\Dapper.FluentMap.Benchmarks.csproj --configuration Release -- --filter "*MaterializationSteadyStateBenchmarks*QueryMappedSimple*" --job Dry`: sucesso; smoke executou cenarios `Dry` e `ShortRun`, registrado em `05-performance-impact.md`. +- `dotnet restore .\Dapper.FluentMap.sln`: sucesso. +- `dotnet build .\Dapper.FluentMap.sln --configuration Release --no-restore`: sucesso, 0 warnings, 0 errors. +- `dotnet test .\Dapper.FluentMap.sln --configuration Release --no-build`: sucesso, 429 testes aprovados. +- `dotnet pack .\src\Dapper.FluentMap\Dapper.FluentMap.csproj --configuration Release --no-build --output .\artifacts\packages`: sucesso; criou `artifacts\packages\Dapper.FluentMap.2.0.0.nupkg`; warning existente `NU5125` sobre `licenseUrl` obsoleto. + ## Ultimo prompt executado -Ultimo prompt executado: 11.2 +Ultimo prompt executado: 11.3 diff --git a/README.md b/README.md index 932f67c..6cbec8d 100644 --- a/README.md +++ b/README.md @@ -337,14 +337,17 @@ var configuration = new FluentMapConfigurationBuilder() .AddMap() .Configure(config => config.AddGeneratedMappings()) .Build(); + +var runtime = new FluentMapRuntime(configuration); ``` `Build()` validates the same invariants used by `FluentMapper.Validate()` and returns an `ImmutableFluentMapConfiguration` with read-only metadata for maps, profiles, conventions, naming policies, persistence metadata, converters and generated materializer registrations. The builder is sealed after `Build()`. -Existing runtime APIs still use the global compatibility layer until isolated -runtime entry points are introduced. +Create a `FluentMapRuntime` from the immutable configuration when multiple +configuration-specific `QueryMapped*` pipelines must coexist in the same +process. `FluentMapper.Initialize(...)` remains the global compatibility layer. ## Conventions and Naming Policies @@ -582,6 +585,24 @@ var orders = multi.ReadMapped(); `QueryMapped*` and `ReadMapped*` return buffered results and are the paths that support nested object materialization, constructor-built value objects and profile-specific mapping. When a generated materializer is registered for the entity, profile and ordered column shape, these APIs use it; otherwise they use the runtime materializer fallback. +For independent configurations in the same process, create a runtime from an +immutable configuration and use its query entry points: + +```csharp +var configuration = new FluentMapConfigurationBuilder() + .AddMap() + .Build(); + +var runtime = new FluentMapRuntime(configuration); +var customers = runtime.QueryMapped( + connection, + "SELECT 7 AS customer_id, 'Ada' AS Name;"); +``` + +The runtime owns configuration-scoped caches for mapping lookup, generated +materializer lookup and runtime materialization plans. It is safe to share across +concurrent queries when the configuration is immutable. + Use `QueryMultipleMapped(...)` when one command returns multiple result sets that all need FluentMap-controlled materialization: ```csharp @@ -704,7 +725,7 @@ persistence behavior that matches the intent: `ReadOnly()`, `Computed()`, ## Current Limitations -- FluentMap configuration is process-wide. Configure at startup and avoid changing mappings while queries are running. +- `FluentMapper.Initialize(...)`, `Dapper.Query()` and Dommel still use process-wide compatibility bridges. Use `ImmutableFluentMapConfiguration` + `FluentMapRuntime` with `QueryMapped*`/`ReadMapped*` when multiple configurations must coexist in the same process. - Assembly scanning depends on reflection discovery and is not the recommended path for trimmed or Native AOT applications. - `QueryMapped*` may use generated materializers for supported flat, nested and Value Object shapes, but it can still fall back to runtime metadata and dynamic code; it is not yet a guaranteed Native AOT-safe materialization path. - Property converters are not a general object mapper, serializer, SQL hook or replacement for Dapper `TypeHandler`. @@ -1077,14 +1098,18 @@ var configuration = new FluentMapConfigurationBuilder() .AddMap() .Configure(config => config.AddGeneratedMappings()) .Build(); + +var runtime = new FluentMapRuntime(configuration); ``` `Build()` valida os mesmos invariants usados por `FluentMapper.Validate()` e retorna um `ImmutableFluentMapConfiguration` com metadata read-only para maps, profiles, conventions, naming policies, persistence metadata, converters e generated materializer registrations. O builder fica selado depois de `Build()`. -As APIs de runtime existentes ainda usam a camada global de compatibilidade ate -que entry points de runtime isolado sejam introduzidos. +Crie um `FluentMapRuntime` a partir da configuracao imutavel quando multiplos +pipelines `QueryMapped*` especificos por configuracao precisarem coexistir no +mesmo processo. `FluentMapper.Initialize(...)` continua sendo a camada global de +compatibilidade. ## Convenções e Políticas de Nomenclatura @@ -1322,6 +1347,25 @@ var orders = multi.ReadMapped(); `QueryMapped*` e `ReadMapped*` retornam resultados bufferizados e são os caminhos que suportam materialização de objetos aninhados, Value Objects construídos por construtor e mapeamento específico por profile. Quando existe materializador gerado para entidade, profile e shape ordenado de colunas, essas APIs o utilizam; caso contrário, usam o fallback de materialização em runtime. +Para configuracoes independentes no mesmo processo, crie um runtime a partir de +uma configuracao imutavel e use seus entry points de consulta: + +```csharp +var configuration = new FluentMapConfigurationBuilder() + .AddMap() + .Build(); + +var runtime = new FluentMapRuntime(configuration); +var customers = runtime.QueryMapped( + connection, + "SELECT 7 AS customer_id, 'Ada' AS Name;"); +``` + +O runtime possui caches escopados por configuracao para lookup de mapping, +lookup de materializador gerado e planos de materializacao runtime. Ele e seguro +para compartilhamento entre queries concorrentes quando a configuracao e +imutavel. + Use `QueryMultipleMapped(...)` quando um comando retorna múltiplos result sets que precisam de materialização controlada pelo FluentMap: ```csharp @@ -1445,7 +1489,7 @@ ainda devem ser lidos, use o persistence behavior correspondente: ## 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. +- `FluentMapper.Initialize(...)`, `Dapper.Query()` e Dommel continuam usando bridges globais/process-wide. Para multiplas configuracoes simultaneas no mesmo processo, use `ImmutableFluentMapConfiguration` + `FluentMapRuntime` com os entry points `QueryMapped*`/`ReadMapped*`. - Assembly scanning depende de descoberta por reflection e não é o caminho recomendado para aplicações com trimming ou Native AOT. - Property converters nao sao object mapper geral, serializer, hook de SQL nem substituto para `TypeHandler` do Dapper. - Write converters sao metadata-only na integracao Dommel atual e nao sao executados por `Insert` ou `Update`. diff --git a/benchmarks/Dapper.FluentMap.Benchmarks/Program.cs b/benchmarks/Dapper.FluentMap.Benchmarks/Program.cs index 435d9fc..42f325e 100644 --- a/benchmarks/Dapper.FluentMap.Benchmarks/Program.cs +++ b/benchmarks/Dapper.FluentMap.Benchmarks/Program.cs @@ -5,6 +5,7 @@ using BenchmarkDotNet.Order; using BenchmarkDotNet.Running; using Dapper; +using Dapper.FluentMap.Configuration; using Dapper.FluentMap.Mapping; using Microsoft.Data.Sqlite; @@ -29,6 +30,7 @@ public class MaterializationSteadyStateBenchmarks private const int RowCount = 1000; private SqliteConnection _connection = null!; + private FluentMapRuntime _runtime = null!; [GlobalSetup] public async Task GlobalSetup() @@ -42,6 +44,9 @@ public async Task GlobalSetup() configuration.AddGeneratedMappings(); }); + _runtime = new FluentMapRuntime(new FluentMapConfigurationBuilder() + .Configure(configuration => configuration.AddGeneratedMappings()) + .Build()); _connection = OpenPopulatedConnection(); DapperPure(); @@ -49,9 +54,13 @@ public async Task GlobalSetup() await DapperPureUnbufferedAsync(); DapperWithFluentMapRootMapping(); QueryMappedSimple(); + RuntimeQueryMappedSimple(); QueryMappedSimpleUnbuffered(); + RuntimeQueryMappedSimpleUnbuffered(); await QueryMappedSimpleUnbufferedAsync(); + await RuntimeQueryMappedSimpleUnbufferedAsync(); QueryMappedSimpleRuntimeFallback(); + RuntimeQueryMappedSimpleRuntimeFallback(); QueryMappedSimpleUnbufferedRuntimeFallback(); await QueryMappedSimpleUnbufferedAsyncRuntimeFallback(); QueryMappedImmutableConstructor(); @@ -61,6 +70,7 @@ public async Task GlobalSetup() QueryMappedValueObjectRuntimeFallback(); DapperQueryMultipleBuffered(); QueryMultipleMappedSimple(); + RuntimeQueryMultipleMappedSimple(); QueryMultipleMappedSimpleRuntimeFallback(); QueryMappedRuntimeNoConverter(); QueryMappedGeneratedSimpleConverter(); @@ -119,6 +129,15 @@ public int QueryMappedSimple() .Count(); } + [Benchmark] + public int RuntimeQueryMappedSimple() + { + return _runtime.QueryMapped( + _connection, + "SELECT Id AS customer_id, Name AS full_name, Age AS customer_age, Balance AS account_balance, CreatedAt AS created_at FROM BenchmarkRows;") + .Count(); + } + [Benchmark] public int QueryMappedSimpleUnbuffered() { @@ -127,6 +146,15 @@ public int QueryMappedSimpleUnbuffered() .Count(); } + [Benchmark] + public int RuntimeQueryMappedSimpleUnbuffered() + { + return _runtime.QueryMappedUnbuffered( + _connection, + "SELECT Id AS customer_id, Name AS full_name, Age AS customer_age, Balance AS account_balance, CreatedAt AS created_at FROM BenchmarkRows;") + .Count(); + } + [Benchmark] public Task QueryMappedSimpleUnbufferedAsync() { @@ -134,6 +162,14 @@ public Task QueryMappedSimpleUnbufferedAsync() "SELECT Id AS customer_id, Name AS full_name, Age AS customer_age, Balance AS account_balance, CreatedAt AS created_at FROM BenchmarkRows;")); } + [Benchmark] + public Task RuntimeQueryMappedSimpleUnbufferedAsync() + { + return CountAsync(_runtime.QueryMappedUnbufferedAsync( + _connection, + "SELECT Id AS customer_id, Name AS full_name, Age AS customer_age, Balance AS account_balance, CreatedAt AS created_at FROM BenchmarkRows;")); + } + [Benchmark] public int QueryMappedSimpleRuntimeFallback() { @@ -142,6 +178,15 @@ public int QueryMappedSimpleRuntimeFallback() .Count(); } + [Benchmark] + public int RuntimeQueryMappedSimpleRuntimeFallback() + { + return _runtime.QueryMapped( + _connection, + "SELECT Name AS full_name, Id AS customer_id, Age AS customer_age, Balance AS account_balance, CreatedAt AS created_at FROM BenchmarkRows;") + .Count(); + } + [Benchmark] public int QueryMappedSimpleUnbufferedRuntimeFallback() { @@ -219,6 +264,18 @@ public int QueryMultipleMappedSimple() multi.ReadMapped().Count(); } + [Benchmark] + public int RuntimeQueryMultipleMappedSimple() + { + using var multi = _runtime.QueryMultipleMapped( + _connection, + @"SELECT Id AS customer_id, Name AS full_name, Age AS customer_age, Balance AS account_balance, CreatedAt AS created_at FROM BenchmarkRows WHERE Id <= 500; + SELECT Id AS customer_id, Name AS full_name, Age AS customer_age, Balance AS account_balance, CreatedAt AS created_at FROM BenchmarkRows WHERE Id > 500;"); + + return multi.ReadMapped().Count() + + multi.ReadMapped().Count(); + } + [Benchmark] public int QueryMultipleMappedSimpleRuntimeFallback() { diff --git a/src/Dapper.FluentMap/Configuration/ImmutableFluentMapConfiguration.cs b/src/Dapper.FluentMap/Configuration/ImmutableFluentMapConfiguration.cs index 3dc88b2..f11775b 100644 --- a/src/Dapper.FluentMap/Configuration/ImmutableFluentMapConfiguration.cs +++ b/src/Dapper.FluentMap/Configuration/ImmutableFluentMapConfiguration.cs @@ -271,6 +271,7 @@ public sealed class PropertyMappingConfiguration { private PropertyMappingConfiguration( string memberPath, + IReadOnlyList memberPathProperties, PropertyInfo propertyInfo, string columnName, bool caseSensitive, @@ -279,6 +280,7 @@ private PropertyMappingConfiguration( PropertyConversionMetadata conversion) { MemberPath = memberPath; + MemberPathProperties = memberPathProperties; PropertyInfo = propertyInfo; ColumnName = columnName; CaseSensitive = caseSensitive; @@ -292,6 +294,8 @@ private PropertyMappingConfiguration( /// public string MemberPath { get; } + internal IReadOnlyList MemberPathProperties { get; } + /// /// Gets the terminal property metadata for the mapped member path. /// @@ -329,8 +333,10 @@ internal static PropertyMappingConfiguration Create(IPropertyMap map) throw new ArgumentNullException(nameof(map)); } + var memberPath = PropertyMapIdentity.GetMemberPath(map); return new PropertyMappingConfiguration( - PropertyMapIdentity.GetMemberPath(map).ToString(), + memberPath.ToString(), + new ReadOnlyCollection(memberPath.Properties.ToList()), map.PropertyInfo, map.ColumnName, map.CaseSensitive, diff --git a/src/Dapper.FluentMap/Configuration/RuntimeConfigurationRegistryFactory.cs b/src/Dapper.FluentMap/Configuration/RuntimeConfigurationRegistryFactory.cs new file mode 100644 index 0000000..219bd6d --- /dev/null +++ b/src/Dapper.FluentMap/Configuration/RuntimeConfigurationRegistryFactory.cs @@ -0,0 +1,177 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Dapper.FluentMap.Conventions; +using Dapper.FluentMap.Mapping; + +namespace Dapper.FluentMap.Configuration +{ + internal static class RuntimeConfigurationRegistryFactory + { + internal static MappingRegistry Create(ImmutableFluentMapConfiguration configuration) + { + if (configuration == null) + { + throw new ArgumentNullException(nameof(configuration)); + } + + var registry = new MappingRegistry(installDapperTypeMaps: false); + + foreach (var map in configuration.EntityMaps.Values) + { + registry.EntityMaps.TryAdd(map.EntityType, SnapshotEntityMap.Create(map)); + } + + foreach (var profile in configuration.ProfileMaps) + { + registry.ProfileMaps.TryAdd( + new MappingProfileKey(profile.EntityType, profile.ProfileType), + SnapshotEntityMap.Create(profile)); + } + + foreach (var conventions in configuration.TypeConventions) + { + registry.TypeConventions.TryAdd( + conventions.Key, + conventions.Value + .Select(convention => (Convention)SnapshotConvention.Create(convention)) + .ToList()); + } + + foreach (var materializer in configuration.GeneratedMaterializers) + { + registry.AddGeneratedMaterializer( + materializer.EntityType, + materializer.ProfileType, + materializer.Columns, + materializer.Materializer); + } + + registry.ValidateConfiguration(); + return registry; + } + + private sealed class SnapshotEntityMap : + IEntityMap, + IEntityMapWithIncludedBaseTypes, + IRuntimeEntityMapMetadata + { + private SnapshotEntityMap(Type mapType, IList propertyMaps, IList includedBaseTypes) + { + MapType = mapType; + PropertyMaps = propertyMaps; + IncludedBaseTypes = includedBaseTypes; + } + + public IList PropertyMaps { get; } + + public IList IncludedBaseTypes { get; } + + public Type MapType { get; } + + internal static SnapshotEntityMap Create(EntityMappingConfiguration map) + { + return new SnapshotEntityMap( + map.MapType, + map.PropertyMaps.Select(SnapshotPropertyMap.Create).Cast().ToList(), + map.IncludedBaseTypes.ToList()); + } + + internal static SnapshotEntityMap Create(ProfileMappingConfiguration map) + { + return new SnapshotEntityMap( + map.MapType, + map.PropertyMaps.Select(SnapshotPropertyMap.Create).Cast().ToList(), + map.IncludedBaseTypes.ToList()); + } + } + + private sealed class SnapshotPropertyMap : + IPropertyMap, + IPropertyMapWithMemberPath, + IPropertyMapWithPersistenceMetadata, + IPropertyMapWithConversionMetadata + { + private SnapshotPropertyMap(PropertyMappingConfiguration propertyMap) + { + ColumnName = propertyMap.ColumnName; + PropertyInfo = propertyMap.PropertyInfo; + CaseSensitive = propertyMap.CaseSensitive; + Ignored = propertyMap.Ignored; + Persistence = propertyMap.Persistence; + Conversion = propertyMap.Conversion; + MemberPath = MemberPath.FromProperties(propertyMap.MemberPathProperties); + } + + public string ColumnName { get; } + + public System.Reflection.PropertyInfo PropertyInfo { get; } + + public bool CaseSensitive { get; } + + public bool Ignored { get; } + + public PropertyPersistenceMetadata Persistence { get; } + + public PropertyConversionMetadata Conversion { get; } + + public MemberPath MemberPath { get; private set; } + + void IPropertyMapWithMemberPath.SetMemberPath(MemberPath memberPath) + { + MemberPath = memberPath; + } + + internal static SnapshotPropertyMap Create(PropertyMappingConfiguration propertyMap) + { + return new SnapshotPropertyMap(propertyMap); + } + } + + private sealed class SnapshotConvention : + Convention, + IRuntimeConventionMetadata + { + private SnapshotConvention(ConventionMappingConfiguration convention) + { + ConventionType = convention.ConventionType; + + foreach (var propertyMap in convention.PropertyMaps) + { + var snapshotMap = new PropertyMap( + propertyMap.PropertyInfo, + propertyMap.ColumnName, + propertyMap.CaseSensitive); + + PropertyMapIdentity.SetMemberPath( + snapshotMap, + MemberPath.FromProperties(propertyMap.MemberPathProperties)); + + if (propertyMap.Ignored) + { + snapshotMap.Ignore(); + } + + PropertyMaps.Add(snapshotMap); + } + } + + public Type ConventionType { get; } + + internal static SnapshotConvention Create(ConventionMappingConfiguration convention) + { + return new SnapshotConvention(convention); + } + } + } + + internal interface IRuntimeEntityMapMetadata + { + Type MapType { get; } + } + + internal interface IRuntimeConventionMetadata + { + Type ConventionType { get; } + } +} diff --git a/src/Dapper.FluentMap/FluentMapRuntime.cs b/src/Dapper.FluentMap/FluentMapRuntime.cs new file mode 100644 index 0000000..194e528 --- /dev/null +++ b/src/Dapper.FluentMap/FluentMapRuntime.cs @@ -0,0 +1,307 @@ +using System; +using System.Collections.Generic; +using System.Data; +using System.Data.Common; +using System.Diagnostics.CodeAnalysis; +using System.Linq; +using System.Threading; +using Dapper.FluentMap.Configuration; +using Dapper.FluentMap.Diagnostics; +using Dapper.FluentMap.Mapping; + +namespace Dapper.FluentMap +{ + /// + /// Represents a thread-safe FluentMap runtime bound to one immutable mapping configuration. + /// + /// + /// Runtime instances own the caches derived from their configuration. They do not own connections, + /// transactions, commands or database resources and do not require disposal. + /// + public sealed class FluentMapRuntime + { + private const DynamicallyAccessedMemberTypes EntityMemberTypes = + DynamicallyAccessedMemberTypes.PublicConstructors | + DynamicallyAccessedMemberTypes.PublicProperties; + + /// + /// Initializes a new instance of the class. + /// + /// The immutable configuration used by this runtime. + public FluentMapRuntime(ImmutableFluentMapConfiguration configuration) + { + Configuration = configuration ?? throw new ArgumentNullException(nameof(configuration)); + Registry = RuntimeConfigurationRegistryFactory.Create(configuration); + } + + internal FluentMapRuntime(MappingRegistry registry) + { + Registry = registry ?? throw new ArgumentNullException(nameof(registry)); + } + + /// + /// Gets the immutable configuration used by this runtime, or for the legacy global runtime. + /// + public ImmutableFluentMapConfiguration Configuration { get; } + + internal MappingRegistry Registry { get; } + + internal int CacheEntryCount => Registry.CacheEntryCount; + + internal int MaterializationPlanCacheEntryCount => Registry.MaterializationPlanCacheEntryCount; + + internal int GeneratedMaterializerCount => Registry.GeneratedMaterializerCount; + + /// + /// Validates this runtime's effective configuration without accessing global FluentMap state. + /// + public 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 MappingExplanation Explain< + [DynamicallyAccessedMembers(EntityMemberTypes)] + TEntity>() + { + 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 MappingExplanation Explain< + [DynamicallyAccessedMembers(EntityMemberTypes)] + TEntity, + TProfile>() + where TProfile : IMappingProfile + { + return Registry.Explain(typeof(TEntity), typeof(TProfile)); + } + + /// + /// Executes a query and materializes rows using this runtime. + /// + [RequiresUnreferencedCode(QueryMappedApiAnnotations.RequiresUnreferencedCodeMessage)] + [RequiresDynamicCode(QueryMappedApiAnnotations.RequiresDynamicCodeMessage)] + public IEnumerable QueryMapped< + [DynamicallyAccessedMembers(QueryMappedApiAnnotations.MaterializedEntityMemberTypes)] + TEntity>( + IDbConnection connection, + string sql, + object param = null, + IDbTransaction transaction = null, + int? commandTimeout = null, + CommandType? commandType = null) + where TEntity : class + { + if (sql == null) + { + throw new ArgumentNullException(nameof(sql)); + } + + return QueryMappedExtensions.ExecuteMapped( + connection, + new CommandDefinition(sql, param, transaction, commandTimeout, commandType), + profileType: null, + runtime: this); + } + + /// + /// Executes a query and materializes rows using this runtime and mapping profile. + /// + [RequiresUnreferencedCode(QueryMappedApiAnnotations.RequiresUnreferencedCodeMessage)] + [RequiresDynamicCode(QueryMappedApiAnnotations.RequiresDynamicCodeMessage)] + public IEnumerable QueryMapped< + [DynamicallyAccessedMembers(QueryMappedApiAnnotations.MaterializedEntityMemberTypes)] + TEntity, + TProfile>( + 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 QueryMappedExtensions.ExecuteMapped( + connection, + new CommandDefinition(sql, param, transaction, commandTimeout, commandType), + typeof(TProfile), + this); + } + + /// + /// Executes a query and materializes exactly one row using this runtime. + /// + [RequiresUnreferencedCode(QueryMappedApiAnnotations.RequiresUnreferencedCodeMessage)] + [RequiresDynamicCode(QueryMappedApiAnnotations.RequiresDynamicCodeMessage)] + public TEntity QueryMappedSingle< + [DynamicallyAccessedMembers(QueryMappedApiAnnotations.MaterializedEntityMemberTypes)] + TEntity>( + 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(); + } + + /// + /// Executes a query and materializes exactly one row using this runtime and mapping profile. + /// + [RequiresUnreferencedCode(QueryMappedApiAnnotations.RequiresUnreferencedCodeMessage)] + [RequiresDynamicCode(QueryMappedApiAnnotations.RequiresDynamicCodeMessage)] + public TEntity QueryMappedSingle< + [DynamicallyAccessedMembers(QueryMappedApiAnnotations.MaterializedEntityMemberTypes)] + TEntity, + TProfile>( + 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(); + } + + /// + /// Creates a lazy unbuffered query that materializes rows using this runtime. + /// + [RequiresUnreferencedCode(QueryMappedApiAnnotations.RequiresUnreferencedCodeMessage)] + [RequiresDynamicCode(QueryMappedApiAnnotations.RequiresDynamicCodeMessage)] + public IEnumerable QueryMappedUnbuffered< + [DynamicallyAccessedMembers(QueryMappedApiAnnotations.MaterializedEntityMemberTypes)] + TEntity>( + IDbConnection connection, + string sql, + object param = null, + IDbTransaction transaction = null, + int? commandTimeout = null, + CommandType? commandType = null) + where TEntity : class + { + if (sql == null) + { + throw new ArgumentNullException(nameof(sql)); + } + + return QueryMappedExtensions.ExecuteMappedUnbuffered( + connection, + new CommandDefinition(sql, param, transaction, commandTimeout, commandType, CommandFlags.None), + profileType: null, + runtime: this); + } + + /// + /// Creates a lazy unbuffered query that materializes rows using this runtime and mapping profile. + /// + [RequiresUnreferencedCode(QueryMappedApiAnnotations.RequiresUnreferencedCodeMessage)] + [RequiresDynamicCode(QueryMappedApiAnnotations.RequiresDynamicCodeMessage)] + public IEnumerable QueryMappedUnbuffered< + [DynamicallyAccessedMembers(QueryMappedApiAnnotations.MaterializedEntityMemberTypes)] + TEntity, + TProfile>( + 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 QueryMappedExtensions.ExecuteMappedUnbuffered( + connection, + new CommandDefinition(sql, param, transaction, commandTimeout, commandType, CommandFlags.None), + typeof(TProfile), + this); + } + + /// + /// Creates a lazy asynchronous unbuffered query that materializes rows using this runtime. + /// + [RequiresUnreferencedCode(QueryMappedApiAnnotations.RequiresUnreferencedCodeMessage)] + [RequiresDynamicCode(QueryMappedApiAnnotations.RequiresDynamicCodeMessage)] + public IAsyncEnumerable QueryMappedUnbufferedAsync< + [DynamicallyAccessedMembers(QueryMappedApiAnnotations.MaterializedEntityMemberTypes)] + TEntity>( + DbConnection connection, + string sql, + object param = null, + IDbTransaction transaction = null, + int? commandTimeout = null, + CommandType? commandType = null, + CancellationToken cancellationToken = default) + where TEntity : class + { + if (sql == null) + { + throw new ArgumentNullException(nameof(sql)); + } + + var command = new CommandDefinition( + sql, + param, + transaction, + commandTimeout, + commandType, + CommandFlags.None, + cancellationToken); + + return QueryMappedExtensions.ExecuteMappedUnbufferedAsync( + connection, + command, + profileType: null, + runtime: this, + cancellationToken); + } + + /// + /// Executes a command and returns a reader for sequential materialization using this runtime. + /// + [RequiresUnreferencedCode(QueryMappedApiAnnotations.RequiresUnreferencedCodeMessage)] + [RequiresDynamicCode(QueryMappedApiAnnotations.RequiresDynamicCodeMessage)] + public MappedGridReader QueryMultipleMapped(IDbConnection connection, string sql, object param = null) + { + if (connection == null) + { + throw new ArgumentNullException(nameof(connection)); + } + + if (sql == null) + { + throw new ArgumentNullException(nameof(sql)); + } + + return new MappedGridReader( + SqlMapper.ExecuteReader(connection, new CommandDefinition(sql, param)), + this); + } + } +} diff --git a/src/Dapper.FluentMap/FluentMapper.cs b/src/Dapper.FluentMap/FluentMapper.cs index 8a90e28..a07291c 100644 --- a/src/Dapper.FluentMap/FluentMapper.cs +++ b/src/Dapper.FluentMap/FluentMapper.cs @@ -19,6 +19,7 @@ public static class FluentMapper DynamicallyAccessedMemberTypes.PublicProperties; private static readonly MappingRegistry _registry = new MappingRegistry(); + private static readonly FluentMapRuntime _runtime = new FluentMapRuntime(_registry); private static readonly FluentMapConfiguration _configuration = new FluentMapConfiguration(); /// @@ -43,6 +44,8 @@ public static class FluentMapper internal static MappingRegistry Registry => _registry; + internal static FluentMapRuntime Runtime => _runtime; + /// /// Initializes Dapper.FluentMap with the specified configuration. /// This is method should be called when the application starts or when the first mapping is needed. @@ -61,7 +64,7 @@ public static void Initialize(Action configure) /// public static void Validate() { - _registry.ValidateConfiguration(); + _runtime.Validate(); } /// @@ -91,7 +94,7 @@ public static MappingExplanation Explain< [DynamicallyAccessedMembers(EntityMemberTypes)] TEntity>() { - return _registry.Explain(typeof(TEntity)); + return _runtime.Explain(); } /// @@ -106,7 +109,7 @@ public static MappingExplanation Explain< TProfile>() where TProfile : IMappingProfile { - return _registry.Explain(typeof(TEntity), typeof(TProfile)); + return _runtime.Explain(); } /// diff --git a/src/Dapper.FluentMap/MappedGridReader.cs b/src/Dapper.FluentMap/MappedGridReader.cs index 6886e8b..68698ee 100644 --- a/src/Dapper.FluentMap/MappedGridReader.cs +++ b/src/Dapper.FluentMap/MappedGridReader.cs @@ -14,12 +14,19 @@ namespace Dapper.FluentMap public sealed class MappedGridReader : IDisposable { private readonly IDataReader _reader; + private readonly FluentMapRuntime _runtime; private bool _disposed; private bool _isConsumed; internal MappedGridReader(IDataReader reader) + : this(reader, FluentMapper.Runtime) + { + } + + internal MappedGridReader(IDataReader reader, FluentMapRuntime runtime) { _reader = reader ?? throw new ArgumentNullException(nameof(reader)); + _runtime = runtime ?? throw new ArgumentNullException(nameof(runtime)); } /// @@ -123,7 +130,7 @@ private IEnumerable ReadMapped< try { - var results = MappedRowMaterializer.Materialize(_reader, profileType); + var results = MappedRowMaterializer.Materialize(_reader, profileType, _runtime); _isConsumed = !_reader.NextResult(); return results; } diff --git a/src/Dapper.FluentMap/MappingRegistry.cs b/src/Dapper.FluentMap/MappingRegistry.cs index e3c4a01..9523bbf 100644 --- a/src/Dapper.FluentMap/MappingRegistry.cs +++ b/src/Dapper.FluentMap/MappingRegistry.cs @@ -292,6 +292,44 @@ internal void AddGeneratedMaterializer(GeneratedMaterializerDescriptor< } } + internal void AddGeneratedMaterializer( + Type entityType, + Type profileType, + IReadOnlyList columns, + Func materializer) + { + if (entityType == null) + { + throw new ArgumentNullException(nameof(entityType)); + } + + if (columns == null) + { + throw new ArgumentNullException(nameof(columns)); + } + + if (materializer == null) + { + throw new ArgumentNullException(nameof(materializer)); + } + + var key = new MaterializationPlanCacheKey( + entityType, + profileType, + columns.Select(column => column.ColumnName)); + var entry = GeneratedMaterializerEntry.Create(columns, materializer); + + if (!_generatedMaterializers.TryAdd(key, entry)) + { + var profileContext = profileType == null + ? string.Empty + : $" and profile '{profileType.FullName}'"; + + throw new FluentMapConfigurationException( + $"Entity '{entityType.FullName}' already has a generated materializer registered for the same column shape{profileContext}."); + } + } + internal bool TryGetGeneratedMaterializer( Type type, Type profileType, @@ -423,7 +461,7 @@ internal MappingExplanation Explain( if (hasEntityMap) { - entityMapType = entityMap.GetType(); + entityMapType = GetEntityMapType(entityMap); foreach (var descriptor in ComposeExplicitPropertyMapDescriptors(type, entityMap, profileType)) { @@ -665,7 +703,7 @@ private IEnumerable GetConventionTypes(Type type) return new Type[0]; } - return conventions.Select(c => c.GetType()).ToList(); + return conventions.Select(GetConventionType).ToList(); } private void ValidateIncludedBaseMaps(Type type, IEntityMap entityMap, Type profileType) @@ -759,6 +797,29 @@ private static IList GetIncludedBaseTypes(IEntityMap entityMap) return mapWithIncludedBases.IncludedBaseTypes; } + private static Type GetEntityMapType(IEntityMap entityMap) + { + var runtimeMetadata = entityMap as IRuntimeEntityMapMetadata; + return runtimeMetadata == null ? entityMap.GetType() : runtimeMetadata.MapType; + } + + private static Type GetConventionType(Convention convention) + { + var runtimeMetadata = convention as IRuntimeConventionMetadata; + return runtimeMetadata == null ? convention.GetType() : runtimeMetadata.ConventionType; + } + + private static bool IsNamingPolicyConvention(Convention convention) + { + if (convention is NamingPolicyConvention) + { + return true; + } + + var runtimeMetadata = convention as IRuntimeConventionMetadata; + return runtimeMetadata != null && runtimeMetadata.ConventionType == typeof(NamingPolicyConvention); + } + private IEnumerable GetConventionPropertyMapDescriptors(Type type, IList configuredPaths) { if (!TypeConventions.TryGetValue(type, out var conventions)) @@ -1005,6 +1066,13 @@ internal static GeneratedMaterializerEntry Create(GeneratedMaterializer record => descriptor.Materializer(record)); } + internal static GeneratedMaterializerEntry Create( + IReadOnlyList columns, + Func materialize) + { + return new GeneratedMaterializerEntry(columns, materialize); + } + internal IReadOnlyList Columns { get; } internal Func Materialize { get; } @@ -1035,11 +1103,11 @@ internal static MappingDiagnosticDescriptor Explicit(IPropertyMap map) internal static MappingDiagnosticDescriptor Convention(IPropertyMap map, Convention convention) { - var source = convention is NamingPolicyConvention + var source = IsNamingPolicyConvention(convention) ? MappingSource.NamingPolicy : MappingSource.Convention; - return new MappingDiagnosticDescriptor(map, source, null, convention.GetType()); + return new MappingDiagnosticDescriptor(map, source, null, GetConventionType(convention)); } internal MappingDiagnosticDescriptor AsInheritedFrom(Type baseType) diff --git a/src/Dapper.FluentMap/Materialization/MappedRowMaterializer.cs b/src/Dapper.FluentMap/Materialization/MappedRowMaterializer.cs index 66d4a0f..fef9075 100644 --- a/src/Dapper.FluentMap/Materialization/MappedRowMaterializer.cs +++ b/src/Dapper.FluentMap/Materialization/MappedRowMaterializer.cs @@ -11,11 +11,12 @@ internal static IEnumerable Materialize< [DynamicallyAccessedMembers(QueryMappedApiAnnotations.MaterializedEntityMemberTypes)] TEntity>( IDataReader reader, - Type profileType) + Type profileType, + FluentMapRuntime runtime) where TEntity : class { var results = new List(); - var materializer = CreateMaterializer(reader, profileType); + var materializer = CreateMaterializer(reader, profileType, runtime); while (reader.Read()) { @@ -29,13 +30,19 @@ internal static Func CreateMaterializer< [DynamicallyAccessedMembers(QueryMappedApiAnnotations.MaterializedEntityMemberTypes)] TEntity>( IDataRecord reader, - Type profileType) + Type profileType, + FluentMapRuntime runtime) where TEntity : class { + if (runtime == null) + { + throw new ArgumentNullException(nameof(runtime)); + } + var columnNames = GetColumnNames(reader); Func generatedMaterializer; - if (FluentMapper.Registry.TryGetGeneratedMaterializer( + if (runtime.Registry.TryGetGeneratedMaterializer( typeof(TEntity), profileType, columnNames, @@ -44,7 +51,7 @@ internal static Func CreateMaterializer< return record => (TEntity)generatedMaterializer(record); } - var plan = FluentMapper.Registry.GetMaterializationPlan(typeof(TEntity), profileType, columnNames); + var plan = runtime.Registry.GetMaterializationPlan(typeof(TEntity), profileType, columnNames); return record => (TEntity)plan.Materialize(record); } diff --git a/src/Dapper.FluentMap/QueryMappedExtensions.cs b/src/Dapper.FluentMap/QueryMappedExtensions.cs index 5a49384..3d80e43 100644 --- a/src/Dapper.FluentMap/QueryMappedExtensions.cs +++ b/src/Dapper.FluentMap/QueryMappedExtensions.cs @@ -109,7 +109,7 @@ public static IEnumerable QueryMapped< CommandDefinition command) where TEntity : class { - return ExecuteMapped(connection, command, profileType: null); + return ExecuteMapped(connection, command, profileType: null, FluentMapper.Runtime); } /// @@ -131,7 +131,7 @@ public static IEnumerable QueryMapped< where TEntity : class where TProfile : IMappingProfile { - return ExecuteMapped(connection, command, typeof(TProfile)); + return ExecuteMapped(connection, command, typeof(TProfile), FluentMapper.Runtime); } /// @@ -231,7 +231,7 @@ public static IEnumerable QueryMappedUnbuffered< CommandDefinition command) where TEntity : class { - return ExecuteMappedUnbuffered(connection, command, profileType: null); + return ExecuteMappedUnbuffered(connection, command, profileType: null, FluentMapper.Runtime); } /// @@ -253,7 +253,7 @@ public static IEnumerable QueryMappedUnbuffered< where TEntity : class where TProfile : IMappingProfile { - return ExecuteMappedUnbuffered(connection, command, typeof(TProfile)); + return ExecuteMappedUnbuffered(connection, command, typeof(TProfile), FluentMapper.Runtime); } /// @@ -421,7 +421,7 @@ public static IAsyncEnumerable QueryMappedUnbufferedAsync< throw new ArgumentNullException(nameof(connection)); } - return ExecuteMappedUnbufferedAsync(connection, command, profileType: null, command.CancellationToken); + return ExecuteMappedUnbufferedAsync(connection, command, profileType: null, FluentMapper.Runtime, command.CancellationToken); } /// @@ -448,7 +448,7 @@ public static IAsyncEnumerable QueryMappedUnbufferedAsync< throw new ArgumentNullException(nameof(connection)); } - return ExecuteMappedUnbufferedAsync(connection, command, typeof(TProfile), command.CancellationToken); + return ExecuteMappedUnbufferedAsync(connection, command, typeof(TProfile), FluentMapper.Runtime, command.CancellationToken); } /// @@ -564,7 +564,7 @@ public static Task> QueryMappedAsync< where TEntity : class where TProfile : IMappingProfile { - return ExecuteMappedAsync(connection, command, typeof(TProfile)); + return ExecuteMappedAsync(connection, command, typeof(TProfile), FluentMapper.Runtime); } /// @@ -652,15 +652,16 @@ public static MappedGridReader QueryMultipleMapped( throw new ArgumentNullException(nameof(connection)); } - return new MappedGridReader(SqlMapper.ExecuteReader(connection, command)); + return new MappedGridReader(SqlMapper.ExecuteReader(connection, command), FluentMapper.Runtime); } - private static IEnumerable ExecuteMapped< + internal static IEnumerable ExecuteMapped< [DynamicallyAccessedMembers(QueryMappedApiAnnotations.MaterializedEntityMemberTypes)] TEntity>( IDbConnection connection, CommandDefinition command, - Type profileType) + Type profileType, + FluentMapRuntime runtime) where TEntity : class { if (connection == null) @@ -668,18 +669,24 @@ private static IEnumerable ExecuteMapped< throw new ArgumentNullException(nameof(connection)); } + if (runtime == null) + { + throw new ArgumentNullException(nameof(runtime)); + } + using (var reader = SqlMapper.ExecuteReader(connection, command)) { - return MappedRowMaterializer.Materialize(reader, profileType); + return MappedRowMaterializer.Materialize(reader, profileType, runtime); } } - private static IEnumerable ExecuteMappedUnbuffered< + internal static IEnumerable ExecuteMappedUnbuffered< [DynamicallyAccessedMembers(QueryMappedApiAnnotations.MaterializedEntityMemberTypes)] TEntity>( IDbConnection connection, CommandDefinition command, - Type profileType) + Type profileType, + FluentMapRuntime runtime) where TEntity : class { if (connection == null) @@ -687,7 +694,12 @@ private static IEnumerable ExecuteMappedUnbuffered< throw new ArgumentNullException(nameof(connection)); } - return ExecuteMappedUnbufferedIterator(connection, command, profileType); + if (runtime == null) + { + throw new ArgumentNullException(nameof(runtime)); + } + + return ExecuteMappedUnbufferedIterator(connection, command, profileType, runtime); } private static IEnumerable ExecuteMappedUnbufferedIterator< @@ -695,12 +707,13 @@ private static IEnumerable ExecuteMappedUnbufferedIterator< TEntity>( IDbConnection connection, CommandDefinition command, - Type profileType) + Type profileType, + FluentMapRuntime runtime) where TEntity : class { using (var reader = SqlMapper.ExecuteReader(connection, command)) { - var materializer = MappedRowMaterializer.CreateMaterializer(reader, profileType); + var materializer = MappedRowMaterializer.CreateMaterializer(reader, profileType, runtime); while (reader.Read()) { @@ -714,7 +727,8 @@ private static async Task> ExecuteMappedAsync< TEntity>( IDbConnection connection, CommandDefinition command, - Type profileType) + Type profileType, + FluentMapRuntime runtime) where TEntity : class { if (connection == null) @@ -722,18 +736,24 @@ private static async Task> ExecuteMappedAsync< throw new ArgumentNullException(nameof(connection)); } + if (runtime == null) + { + throw new ArgumentNullException(nameof(runtime)); + } + using (var reader = await SqlMapper.ExecuteReaderAsync(connection, command).ConfigureAwait(false)) { - return MappedRowMaterializer.Materialize(reader, profileType); + return MappedRowMaterializer.Materialize(reader, profileType, runtime); } } - private static async IAsyncEnumerable ExecuteMappedUnbufferedAsync< + internal static async IAsyncEnumerable ExecuteMappedUnbufferedAsync< [DynamicallyAccessedMembers(QueryMappedApiAnnotations.MaterializedEntityMemberTypes)] TEntity>( DbConnection connection, CommandDefinition command, Type profileType, + FluentMapRuntime runtime, [EnumeratorCancellation] CancellationToken cancellationToken = default) where TEntity : class { @@ -742,6 +762,11 @@ private static async IAsyncEnumerable ExecuteMappedUnbufferedAsync< throw new ArgumentNullException(nameof(connection)); } + if (runtime == null) + { + throw new ArgumentNullException(nameof(runtime)); + } + DbDataReader reader = null; try @@ -750,7 +775,7 @@ private static async IAsyncEnumerable ExecuteMappedUnbufferedAsync< var effectiveCommand = WithCancellation(command, cancellationToken); reader = await SqlMapper.ExecuteReaderAsync(connection, effectiveCommand).ConfigureAwait(false); - var materializer = MappedRowMaterializer.CreateMaterializer(reader, profileType); + var materializer = MappedRowMaterializer.CreateMaterializer(reader, profileType, runtime); while (true) { diff --git a/test/Dapper.FluentMap.Tests/IsolatedRuntimeTests.cs b/test/Dapper.FluentMap.Tests/IsolatedRuntimeTests.cs new file mode 100644 index 0000000..8103ee8 --- /dev/null +++ b/test/Dapper.FluentMap.Tests/IsolatedRuntimeTests.cs @@ -0,0 +1,439 @@ +using System; +using System.Collections.Generic; +using System.Data; +using System.Linq; +using System.Threading.Tasks; +using Dapper.FluentMap.Configuration; +using Dapper.FluentMap.Mapping; +using Dapper.FluentMap.Materialization; +using Microsoft.Data.Sqlite; +using Xunit; + +namespace Dapper.FluentMap.Tests +{ + public class IsolatedRuntimeTests + { + [Fact] + [Trait("Category", "Integration")] + public void RuntimeShouldMaterializeSameEntityWithIndependentConfigurations() + { + var current = CreateRuntime(builder => builder.AddMap(new CurrentCustomerMap())); + var legacy = CreateRuntime(builder => builder.AddMap(new LegacyCustomerMap())); + + using (var connection = OpenConnection()) + { + var currentCustomer = current.QueryMappedSingle( + connection, + "SELECT 1 AS customer_id, 'Ada' AS customer_name;"); + var legacyCustomer = legacy.QueryMappedSingle( + connection, + "SELECT 2 AS customer_id, 'Grace' AS legacy_name;"); + + Assert.Equal(1, currentCustomer.Id); + Assert.Equal("Ada", currentCustomer.Name); + Assert.Equal(2, legacyCustomer.Id); + Assert.Equal("Grace", legacyCustomer.Name); + Assert.Equal(1, current.MaterializationPlanCacheEntryCount); + Assert.Equal(1, legacy.MaterializationPlanCacheEntryCount); + } + } + + [Fact] + [Trait("Category", "Integration")] + public void RuntimeShouldKeepSameProfileTypeIsolatedAcrossConfigurations() + { + var first = CreateRuntime(builder => builder.AddProfile()); + var second = CreateRuntime(builder => builder.AddProfile()); + + using (var connection = OpenConnection()) + { + var firstCustomer = first.QueryMappedSingle( + connection, + "SELECT 7 AS legacy_id, 'First' AS legacy_name;"); + var secondCustomer = second.QueryMappedSingle( + connection, + "SELECT 8 AS profile_id, 'Second' AS profile_name;"); + + Assert.Equal(7, firstCustomer.Id); + Assert.Equal("First", firstCustomer.Name); + Assert.Equal(8, secondCustomer.Id); + Assert.Equal("Second", secondCustomer.Name); + } + } + + [Fact] + [Trait("Category", "Integration")] + public void RuntimeShouldScopeGeneratedMaterializersToConfiguration() + { + var generatedA = CreateRuntime(builder => + { + builder.AddMap(new CurrentCustomerMap()); + builder.AddGeneratedMaterializer( + CustomerGeneratedColumns(), + record => new RuntimeCustomer + { + Id = Convert.ToInt32(record.GetValue(0)), + Name = "A:" + Convert.ToString(record.GetValue(1)) + }); + }); + var generatedB = CreateRuntime(builder => + { + builder.AddMap(new CurrentCustomerMap()); + builder.AddGeneratedMaterializer( + CustomerGeneratedColumns(), + record => new RuntimeCustomer + { + Id = Convert.ToInt32(record.GetValue(0)), + Name = "B:" + Convert.ToString(record.GetValue(1)) + }); + }); + + using (var connection = OpenConnection()) + { + var first = generatedA.QueryMappedSingle( + connection, + "SELECT 1 AS customer_id, 'Ada' AS customer_name;"); + var second = generatedB.QueryMappedSingle( + connection, + "SELECT 1 AS customer_id, 'Ada' AS customer_name;"); + + Assert.Equal("A:Ada", first.Name); + Assert.Equal("B:Ada", second.Name); + Assert.Equal(0, generatedA.MaterializationPlanCacheEntryCount); + Assert.Equal(0, generatedB.MaterializationPlanCacheEntryCount); + Assert.Equal(1, generatedA.GeneratedMaterializerCount); + Assert.Equal(1, generatedB.GeneratedMaterializerCount); + } + } + + [Fact] + [Trait("Category", "Integration")] + public void RuntimeShouldScopeConvertersToConfiguration() + { + var upper = CreateRuntime(builder => builder.AddMap(new UpperConverterCustomerMap())); + var bracket = CreateRuntime(builder => builder.AddMap(new BracketConverterCustomerMap())); + + using (var connection = OpenConnection()) + { + var upperCustomer = upper.QueryMappedSingle( + connection, + "SELECT 1 AS customer_id, 'ada' AS customer_name;"); + var bracketCustomer = bracket.QueryMappedSingle( + connection, + "SELECT 1 AS customer_id, 'ada' AS customer_name;"); + + Assert.Equal("ADA", upperCustomer.Name); + Assert.Equal("[ada]", bracketCustomer.Name); + } + } + + [Fact] + [Trait("Category", "Integration")] + public void RuntimeShouldMaterializeNestedMappingsFromItsConfiguration() + { + var current = CreateRuntime(builder => builder.AddMap(new CurrentNestedCustomerMap())); + var legacy = CreateRuntime(builder => builder.AddMap(new LegacyNestedCustomerMap())); + + using (var connection = OpenConnection()) + { + var currentCustomer = current.QueryMappedSingle( + connection, + "SELECT 1 AS customer_id, 'Sao Paulo' AS city;"); + var legacyCustomer = legacy.QueryMappedSingle( + connection, + "SELECT 1 AS customer_id, 'Campinas' AS legacy_city;"); + + Assert.NotNull(currentCustomer.Address); + Assert.NotNull(legacyCustomer.Address); + Assert.Equal("Sao Paulo", currentCustomer.Address.City); + Assert.Equal("Campinas", legacyCustomer.Address.City); + } + } + + [Fact] + [Trait("Category", "Integration")] + public void RuntimeShouldUseConfigurationScopedMaterializationPlanCache() + { + var nameRuntime = CreateRuntime(builder => builder.AddMap(new SharedShapeNameMap())); + var legacyRuntime = CreateRuntime(builder => builder.AddMap(new SharedShapeLegacyNameMap())); + + using (var connection = OpenConnection()) + { + var name = nameRuntime.QueryMappedSingle( + connection, + "SELECT 'value' AS shared_name;"); + var legacy = legacyRuntime.QueryMappedSingle( + connection, + "SELECT 'value' AS shared_name;"); + + Assert.Equal("value", name.Name); + Assert.Null(name.LegacyName); + Assert.Null(legacy.Name); + Assert.Equal("value", legacy.LegacyName); + Assert.Equal(1, nameRuntime.MaterializationPlanCacheEntryCount); + Assert.Equal(1, legacyRuntime.MaterializationPlanCacheEntryCount); + } + } + + [Fact] + [Trait("Category", "Integration")] + public void RuntimeShouldIntegrateWithReadMappedAndUnbufferedQueries() + { + var runtime = CreateRuntime(builder => builder.AddMap(new CurrentCustomerMap())); + + using (var connection = OpenConnection()) + using (var multi = runtime.QueryMultipleMapped( + connection, + "SELECT 1 AS customer_id, 'Ada' AS customer_name;")) + { + var first = multi.ReadMappedSingle(); + var second = runtime.QueryMappedUnbuffered( + connection, + "SELECT 3 AS customer_id, 'Katherine' AS customer_name;") + .Single(); + + Assert.Equal("Ada", first.Name); + Assert.Equal("Katherine", second.Name); + } + } + + [Fact] + [Trait("Category", "Integration")] + public async Task RuntimeShouldIntegrateWithAsyncStreaming() + { + var runtime = CreateRuntime(builder => builder.AddMap(new CurrentCustomerMap())); + + using (var connection = new SqliteConnection("Data Source=:memory:")) + { + var customers = await ToListAsync(runtime.QueryMappedUnbufferedAsync( + connection, + "SELECT 1 AS customer_id, 'Ada' AS customer_name UNION ALL SELECT 2 AS customer_id, 'Grace' AS customer_name;", + cancellationToken: TestContext.Current.CancellationToken)); + + Assert.Collection( + customers, + customer => Assert.Equal("Ada", customer.Name), + customer => Assert.Equal("Grace", customer.Name)); + Assert.Equal(ConnectionState.Closed, connection.State); + } + } + + [Fact] + [Trait("Category", "Integration")] + public void RuntimeShouldSupportConcurrentQueriesUsingSameImmutableConfiguration() + { + var runtime = CreateRuntime(builder => builder.AddMap(new CurrentCustomerMap())); + + var results = Enumerable.Range(0, 32) + .AsParallel() + .Select(index => + { + using (var connection = OpenConnection()) + { + var customer = runtime.QueryMappedSingle( + connection, + $"SELECT {index} AS customer_id, 'customer-{index}' AS customer_name;"); + + return customer.Id == index && customer.Name == $"customer-{index}"; + } + }) + .ToList(); + + Assert.All(results, Assert.True); + Assert.Equal(1, runtime.MaterializationPlanCacheEntryCount); + } + + [Fact] + public void RuntimeDiagnosticsShouldUseItsConfigurationWithoutGlobalState() + { + var current = CreateRuntime(builder => builder.AddMap(new CurrentCustomerMap())); + var legacy = CreateRuntime(builder => builder.AddMap(new LegacyCustomerMap())); + + current.Validate(); + legacy.Validate(); + + var currentName = current.Explain() + .Members + .Single(member => member.MemberPath == nameof(RuntimeCustomer.Name)); + var legacyName = legacy.Explain() + .Members + .Single(member => member.MemberPath == nameof(RuntimeCustomer.Name)); + + Assert.Equal("customer_name", currentName.ColumnName); + Assert.Equal("legacy_name", legacyName.ColumnName); + } + + private static FluentMapRuntime CreateRuntime(Action configure) + { + var builder = new FluentMapConfigurationBuilder(); + configure(builder); + return new FluentMapRuntime(builder.Build()); + } + + private static GeneratedMaterializerColumn[] CustomerGeneratedColumns() + { + return new[] + { + GeneratedMaterializerColumn.Map("customer_id", nameof(RuntimeCustomer.Id)), + GeneratedMaterializerColumn.Map("customer_name", nameof(RuntimeCustomer.Name)) + }; + } + + private static SqliteConnection OpenConnection() + { + var connection = new SqliteConnection("Data Source=:memory:"); + connection.Open(); + return connection; + } + + private static async Task> ToListAsync(IAsyncEnumerable source) + { + var results = new List(); + + await foreach (var item in source) + { + results.Add(item); + } + + return results; + } + + private sealed class RuntimeLegacyProfile : IMappingProfile + { + } + + private sealed class RuntimeCustomer + { + public int Id { get; set; } + + public string Name { get; set; } + } + + private sealed class CurrentCustomerMap : EntityMap + { + public CurrentCustomerMap() + { + Map(customer => customer.Id).ToColumn("customer_id"); + Map(customer => customer.Name).ToColumn("customer_name"); + } + } + + private sealed class LegacyCustomerMap : EntityMap + { + public LegacyCustomerMap() + { + Map(customer => customer.Id).ToColumn("customer_id"); + Map(customer => customer.Name).ToColumn("legacy_name"); + } + } + + private sealed class FirstProfileCustomerMap : + EntityMap, + IProfileMap + { + public FirstProfileCustomerMap() + { + Map(customer => customer.Id).ToColumn("legacy_id"); + Map(customer => customer.Name).ToColumn("legacy_name"); + } + } + + private sealed class SecondProfileCustomerMap : + EntityMap, + IProfileMap + { + public SecondProfileCustomerMap() + { + Map(customer => customer.Id).ToColumn("profile_id"); + Map(customer => customer.Name).ToColumn("profile_name"); + } + } + + private sealed class UpperConverterCustomerMap : EntityMap + { + public UpperConverterCustomerMap() + { + Map(customer => customer.Id).ToColumn("customer_id"); + Map(customer => customer.Name).ToColumn("customer_name").ConvertFromDatabaseUsing(); + } + } + + private sealed class BracketConverterCustomerMap : EntityMap + { + public BracketConverterCustomerMap() + { + Map(customer => customer.Id).ToColumn("customer_id"); + Map(customer => customer.Name).ToColumn("customer_name").ConvertFromDatabaseUsing(); + } + } + + private sealed class UpperNameConverter : IReadPropertyConverter + { + public string ConvertFromDatabase(string value) + { + return value.ToUpperInvariant(); + } + } + + private sealed class BracketNameConverter : IReadPropertyConverter + { + public string ConvertFromDatabase(string value) + { + return "[" + value + "]"; + } + } + + private sealed class NestedRuntimeCustomer + { + public int Id { get; set; } + + public RuntimeAddress Address { get; set; } + } + + private sealed class RuntimeAddress + { + public string City { get; set; } + } + + private sealed class CurrentNestedCustomerMap : EntityMap + { + public CurrentNestedCustomerMap() + { + Map(customer => customer.Id).ToColumn("customer_id"); + Map(customer => customer.Address.City).ToColumn("city"); + } + } + + private sealed class LegacyNestedCustomerMap : EntityMap + { + public LegacyNestedCustomerMap() + { + Map(customer => customer.Id).ToColumn("customer_id"); + Map(customer => customer.Address.City).ToColumn("legacy_city"); + } + } + + private sealed class SharedShapeCustomer + { + public string Name { get; set; } + + public string LegacyName { get; set; } + } + + private sealed class SharedShapeNameMap : EntityMap + { + public SharedShapeNameMap() + { + Map(customer => customer.Name).ToColumn("shared_name"); + } + } + + private sealed class SharedShapeLegacyNameMap : EntityMap + { + public SharedShapeLegacyNameMap() + { + Map(customer => customer.LegacyName).ToColumn("shared_name"); + } + } + } +} From b2a47bcbf7383cb9b479bb2c9f76ca0033ee9bbf Mon Sep 17 00:00:00 2001 From: Rodrigo de Oliveira Date: Wed, 29 Jul 2026 09:18:25 -0300 Subject: [PATCH 32/49] refactor(configuration): bridge static API to isolated runtime --- .../01-historical-configuration-issues.md | 13 + .sdd/etapa-11/06-compatibility-bridge.md | 257 +++++++++++++++ .sdd/etapa-11/DECISIONS.md | 54 ++++ .sdd/etapa-11/STATUS.md | 48 ++- README.md | 15 +- .../FluentConventionConfiguration.cs | 2 +- .../Configuration/FluentMapConfiguration.cs | 2 +- .../ImmutableFluentMapConfiguration.cs | 9 + src/Dapper.FluentMap/FluentMapper.cs | 105 ++++++- .../DommelPersistenceIntegrationTests.cs | 1 + .../DommelHistoricalRegressionTests.cs | 1 + .../ManualMappingTests.cs | 1 + .../CompatibilityBridgeTests.cs | 295 ++++++++++++++++++ .../IsolatedRuntimeTests.cs | 2 +- 14 files changed, 770 insertions(+), 35 deletions(-) create mode 100644 .sdd/etapa-11/06-compatibility-bridge.md create mode 100644 test/Dapper.FluentMap.Tests/CompatibilityBridgeTests.cs diff --git a/.sdd/etapa-11/01-historical-configuration-issues.md b/.sdd/etapa-11/01-historical-configuration-issues.md index 5bc17c4..dbe7b42 100644 --- a/.sdd/etapa-11/01-historical-configuration-issues.md +++ b/.sdd/etapa-11/01-historical-configuration-issues.md @@ -18,6 +18,14 @@ O fork possui um `FluentMapper.Reset(params Type[])` interno usado pelos testes. O estado atual melhora o isolamento de testes internos, mas nao resolve a causa estrutural. Caches e resolvers ainda dependem do registry global, e `QueryMapped*`, type maps Dapper e Dommel ainda leem esse estado global. +Atualizacao do prompt 11.4: `QueryMapped*`, `ReadMapped*`, generated +materializers e diagnostics `Explain` agora usam o `FluentMapRuntime` default +publicado pela bridge estatica. `FluentMapper.Initialize(...)` reconstroi esse +runtime a partir de uma configuracao imutavel. `Dapper.Query()` continua +process-wide por causa de `SqlMapper.SetTypeMap`, e Dommel continua bridge +process-wide por depender de metadata especifica de `DommelEntityMap` e +`DommelPropertyMap`. + ### Solução simples possível Uma API publica como `FluentMapper.Reset()` ou `ClearConfiguration()` poderia: @@ -45,6 +53,11 @@ A API estatica deve virar camada de compatibilidade que possui um runtime defaul Na Etapa 11, a direcao e especificar e implementar incrementalmente configuracoes imutaveis e runtime isolado. `FluentMapper.Initialize(...)` deve continuar funcionando, mas como bridge para o runtime default. Nao remover a API estatica e nao promover `Reset()` como API principal. +Decisao do prompt 11.4: nao criar `Reset()` publico. A solucao para novos +consumidores e `FluentMapConfigurationBuilder -> Build() -> +configuration.CreateRuntime()`. O reset interno permanece para testes e +compatibilidade. + ## Issue #79 Fonte: https://github.com/henkmollema/Dapper-FluentMap/issues/79 diff --git a/.sdd/etapa-11/06-compatibility-bridge.md b/.sdd/etapa-11/06-compatibility-bridge.md new file mode 100644 index 0000000..507e6c2 --- /dev/null +++ b/.sdd/etapa-11/06-compatibility-bridge.md @@ -0,0 +1,257 @@ +# Compatibility Bridge + +## Objetivo + +O prompt 11.4 torna a arquitetura configuration-aware consumivel e transforma +`FluentMapper` em uma bridge sobre o modelo: + +```text +builder default mutavel + -> ImmutableFluentMapConfiguration + -> FluentMapRuntime default +``` + +Nao ha um segundo runtime global para `QueryMapped*`, diagnostics estruturados +ou type maps Dapper. O caminho estatico publica um runtime default novo sempre +que `Initialize(...)` conclui, ou quando uma chamada parcialmente aplicada +falha depois de registrar algum estado valido. + +## New configuration-aware entry points + +A menor superficie publica adicionada e: + +```csharp +var configuration = new FluentMapConfigurationBuilder() + .AddMap() + .Build(); + +var runtime = configuration.CreateRuntime(); + +var customers = runtime.QueryMapped( + connection, + "SELECT 1 AS customer_id, 'Ada' AS name;"); +``` + +`FluentMapRuntime` ja possui entry points de instancia para: + +- `QueryMapped()`; +- `QueryMapped()`; +- `QueryMappedSingle()`; +- `QueryMappedSingle()`; +- `QueryMappedUnbuffered()`; +- `QueryMappedUnbuffered()`; +- `QueryMappedUnbufferedAsync()`; +- `QueryMultipleMapped(...)`. + +Nao foi introduzido `AsyncLocal`. Configuracoes especificas continuam sendo +explicitas: o caller cria ou recebe um `FluentMapRuntime`. + +## Default configuration + +`FluentMapper.Configuration` expoe o `ImmutableFluentMapConfiguration` +atualmente publicado pela bridge estatica. + +`FluentMapper.Runtime` expoe o `FluentMapRuntime` default atualmente usado por: + +- `QueryMappedExtensions`; +- `MappedGridReader` criado pelos helpers estaticos; +- `FluentMapper.Explain()`; +- type maps Dapper instalados pela bridge. + +Essas propriedades sao snapshots efetivos do estado publicado. Uma nova chamada +de `Initialize(...)` pode trocar a instancia de runtime default. + +## Static initialization + +`FluentMapper.Initialize(...)` agora executa sob lock: + +```text +configure(default FluentMapConfiguration) + -> snapshot imutavel + -> runtime default + -> instalacao de SqlMapper.SetTypeMap para maps/conventions default +``` + +O `FluentMapConfiguration` historico continua sendo a fachada mutavel aceita +por `Initialize(...)`, mas escreve no builder default, nao no runtime publicado. +Depois da publicacao, consultas estaticas usam o runtime default. + +## Lifecycle + +Lifecycle recomendado para codigo novo: + +```text +startup/composition root: + builder mutavel + -> Build() + -> configuration.CreateRuntime() + +runtime: + usar runtime explicitamente em QueryMapped* +``` + +Lifecycle legado: + +```text +startup: + FluentMapper.Initialize(...) + +runtime: + Dapper.Query() para mapping raiz global + QueryMapped* para materializacao FluentMap controlada pelo runtime default +``` + +## Repeated Initialize + +O comportamento aditivo historico foi preservado. Chamadas repetidas a +`FluentMapper.Initialize(...)` acumulam registros no builder default e publicam +um novo runtime default apos cada chamada bem-sucedida. + +Inicializacoes concorrentes sao serializadas por lock. Isso evita corridas entre +duas chamadas de `Initialize(...)`; nao transforma a bridge global em uma API +multi-tenant para trocar configuracao durante queries em andamento. + +Se uma chamada falhar depois de registrar parte do estado, a bridge tenta +publicar um runtime a partir do estado valido restante antes de relancar a +excecao. Isso preserva o comportamento historico em que registros realizados +antes do erro ficavam observaveis. + +## Mutation attempts + +`FluentMapConfigurationBuilder.Build()` continua sendo o limite de imutabilidade +para configuracoes novas. Mutacoes posteriores no builder sao rejeitadas. + +Na bridge estatica, as colecoes legadas continuam mutaveis por compatibilidade. +Mutar essas colecoes diretamente altera o builder default legado, mas nao +reescreve automaticamente o runtime default ja publicado. Uma chamada posterior +a `Initialize(_ => { })` publica um novo runtime a partir do estado atual do +builder default. + +## Legacy dictionaries + +As APIs abaixo foram mantidas como campos publicos por compatibilidade de fonte +e binaria: + +- `FluentMapper.EntityMaps`; +- `FluentMapper.TypeConventions`. + +Estrategia escolhida: manter como colecoes mutaveis legadas ligadas ao builder +default, nao como runtime configuration-aware. + +Consequencias: + +- `GetEntityMaps()` e `GetTypeConventions()` preservam snapshots das instancias + historicas registradas; +- mutacao direta continua podendo bypassar validacao, cache invalidation e + instalacao de type maps Dapper; +- consultas ja publicadas usam `FluentMapper.Runtime`; +- codigo novo deve usar `FluentMapConfigurationBuilder` ou `Initialize(...)`. + +Nenhuma API foi marcada como obsolete neste prompt para evitar ruído de upgrade. +A documentacao desencoraja uso novo das colecoes mutaveis. + +## Deprecated APIs + +Nao houve remocao nem nova marcacao `[Obsolete]`. + +Possiveis obsoletions futuras: + +- `FluentMapper.EntityMaps`; +- `FluentMapper.TypeConventions`; +- construcao direta de `FluentMapConfiguration` fora de `Initialize(...)`. + +Essas mudancas exigem revisao propria de compatibilidade. + +## Diagnostics + +`FluentMapper.Explain()` usa o runtime default publicado. + +`FluentMapper.Validate()` valida o builder default legado e o runtime default. +Isso preserva diagnostics para consumidores/testes que ainda inserem maps +diretamente nas colecoes legadas, ao mesmo tempo em que mantem o runtime +configuration-aware como fonte das consultas. + +`FluentMapRuntime.Validate()` e `FluentMapRuntime.Explain()` continuam +isolados e nao acessam estado global. + +## Query APIs + +Os helpers estaticos continuam usando `FluentMapper.Runtime`: + +- `connection.QueryMapped(...)`; +- `connection.QueryMappedSingle(...)`; +- `connection.QueryMappedUnbuffered(...)`; +- `connection.QueryMappedUnbufferedAsync(...)`; +- `connection.QueryMultipleMapped(...)`. + +O runtime default e substituido por publicacao atomica de referencia. Runtimes +isolados criados por `configuration.CreateRuntime()` possuem caches proprios e +podem coexistir no mesmo processo. + +## Dapper type maps + +`FluentMapper.Initialize(...)` continua instalando `SqlMapper.SetTypeMap` para +entidades com maps default e conventions default. Esses type maps consultam o +runtime default atual quando Dapper resolve membros. + +Limite preservado: `Dapper.Query()` e global por tipo. Ele nao consegue +selecionar configuracao por chamada. Para multiplas configuracoes simultaneas, +usar os entry points de `FluentMapRuntime`. + +## Dommel + +Dommel permanece bridge process-wide. Os resolvers de Dommel continuam lendo as +colecoes legadas porque precisam preservar metadata especifica de +`DommelEntityMap` e `DommelPropertyMap`, que nao pertence ao snapshot imutavel +do core. + +Nao foi prometido isolamento completo de Dommel neste prompt. Isso exige design +proprio dos extension points globais de `DommelMapper`. + +## Reset + +Nao foi introduzido `Reset()` publico. + +Decisao: reset global continua ferramenta interna de teste/compatibilidade. Ele +nao resolve a causa da Issue #101, pois: + +- queries em andamento poderiam observar troca global; +- `SqlMapper.SetTypeMap` e process-wide; +- Dommel tambem e process-wide; +- caches e generated materializers precisam pertencer a runtimes especificos. + +Para novos consumidores, a resposta arquitetural e criar configuracoes e +runtimes isolados. + +## Migration strategy + +Migracao recomendada: + +1. Manter `FluentMapper.Initialize(...)` para codigo existente. +2. Para novos cenarios com uma unica configuracao, continuar usando a bridge + estatica se `Dapper.Query()` global for desejado. +3. Para testes, multi-tenant ou bancos com schemas diferentes, criar + `FluentMapConfigurationBuilder`, chamar `Build()`, depois + `configuration.CreateRuntime()`. +4. Substituir `connection.QueryMapped(...)` por + `runtime.QueryMapped(connection, ...)` nos pontos que precisam de + configuracao especifica. +5. Evitar mutacao direta de `EntityMaps` e `TypeConventions`. + +## Compatibility + +Compatibilidade preservada: + +- nenhuma API estatica removida; +- campos publicos legados mantidos; +- `Initialize` segue aditivo; +- `GetEntityMaps()` preserva instancias historicas registradas; +- generated registrations continuam funcionando pela DSL historica; +- profiles, converters e generated materializers funcionam no runtime default e + em runtimes isolados. + +Mudanca comportamental intencional: + +- `FluentMapper.Runtime`/`Configuration` representam o runtime/configuracao + publicados; mutacao direta das colecoes legadas nao altera queries ja + publicadas ate nova publicacao via `Initialize(...)`. diff --git a/.sdd/etapa-11/DECISIONS.md b/.sdd/etapa-11/DECISIONS.md index 8d1c777..4db5930 100644 --- a/.sdd/etapa-11/DECISIONS.md +++ b/.sdd/etapa-11/DECISIONS.md @@ -256,3 +256,57 @@ singleton por um `MappingRegistry` injetado internamente. A etapa fica aditiva e preserva `Initialize`. O builder consegue reutilizar extensoes existentes via `Configure(Action)`, enquanto o snapshot evita expor maps/conventions mutaveis como configuracao efetiva. + +## ADR-14 - Static compatibility bridge publication + +### Contexto + +Depois do runtime isolado, a API estatica ainda precisava deixar de ser a +implementacao primaria. Ao mesmo tempo, `Initialize` aditivo, type maps Dapper +globais e campos publicos legados precisavam continuar existindo. + +### Decisao + +`FluentMapper.Initialize(...)` escreve em um builder default legado, cria um +`ImmutableFluentMapConfiguration`, publica um `FluentMapRuntime` default e +instala type maps Dapper que consultam esse runtime. `FluentMapper.Runtime` e +`FluentMapper.Configuration` expoem o estado publicado. + +### Alternativas consideradas + +- Manter o `MappingRegistry` global como runtime principal. +- Transformar os campos publicos em propriedades/proxies. +- Usar `AsyncLocal` para selecionar configuracao. + +### Consequencias + +Consultas estaticas e diagnostics estruturados usam o mesmo runtime que a API +isolada. Os campos publicos continuam existindo por compatibilidade, mas sao +colecoes legadas do builder default e nao o mecanismo recomendado para codigo +novo. + +## ADR-15 - Dommel remains a legacy process-wide bridge + +### Contexto + +Os snapshots imutaveis do core preservam metadata do core, mas nao podem +recriar tipos especificos de `Dapper.FluentMap.Dommel`, como +`DommelEntityMap` e `DommelPropertyMap`, sem acoplar o core ao pacote Dommel. + +### Decisao + +Dommel permanece usando as colecoes legadas process-wide nesta etapa. Nao foi +prometido isolamento por runtime para Dommel. + +### Alternativas consideradas + +- Fazer os resolvers Dommel lerem `FluentMapper.GetEntityMaps()`. +- Adicionar tipos de snapshot Dommel no core. +- Usar estado ambiente para escolher runtime por operacao Dommel. + +### Consequencias + +O core avanca para runtime isolado sem transformar Dommel em parte da +configuracao imutavel do core. Multiplas configuracoes Dommel no mesmo processo +continuam fora do contrato ate design especifico dos extension points globais de +`DommelMapper`. diff --git a/.sdd/etapa-11/STATUS.md b/.sdd/etapa-11/STATUS.md index add91a5..8fd67c2 100644 --- a/.sdd/etapa-11/STATUS.md +++ b/.sdd/etapa-11/STATUS.md @@ -41,20 +41,25 @@ e preparando configuracoes imutaveis com runtime isolado. - Criados testes de runtime isolado para duas configuracoes da mesma entidade, mesmo profile type em configuracoes diferentes, generated materializers, converters, nested mappings, cache isolation, `ReadMapped`, unbuffered, async streaming, diagnostics e concorrencia. - Benchmarks existentes foram estendidos com cenarios `RuntimeQueryMapped*` comparaveis aos helpers estaticos. - `README.md` atualizado para documentar `FluentMapRuntime` e os limites restantes de `FluentMapper`, Dapper puro e Dommel. +- Criado `06-compatibility-bridge.md`. +- `ImmutableFluentMapConfiguration.CreateRuntime()` foi adicionado como entry point ergonomico para runtime isolado. +- `FluentMapper.Configuration` e `FluentMapper.Runtime` foram expostos para o runtime/configuracao default publicados pela bridge estatica. +- `FluentMapper.Initialize(...)` agora publica um runtime default criado de snapshot imutavel e serializa inicializacoes concorrentes. +- A bridge estatica preserva `Initialize` aditivo e reinstala type maps Dapper para maps/conventions default. +- `GetEntityMaps()` e `GetTypeConventions()` continuam retornando snapshots das colecoes historicas registradas, preservando instancias de maps/conventions. +- Dommel foi mantido como bridge process-wide sobre as colecoes legadas porque depende de metadata especifica de `DommelEntityMap` e `DommelPropertyMap`. +- Criados testes de compatibility bridge para runtime default, API configuration-aware, equivalencia legacy/new, repeated Initialize, Initialize concorrente, colecoes legadas, generated materializers, profiles e converters. ## Em andamento -- Restore/build/test completos. -- Revisao final de diff. -- Commit semantico do prompt 11.3. +- Nenhum item em andamento para o prompt 11.4. ## Proximos passos -1. Expandir overloads publicos configuration-aware se a API desejada for extension methods em vez de metodos de instancia. -2. Projetar DI em incremento separado. -3. Endurecer Dommel em design proprio, mantendo honestos os limites process-wide de `DommelMapper`. -4. Avaliar full benchmark antes de release. -5. Migrar gradualmente testes antigos de isolamento/concurrencia para runtime instanciado quando isso reduzir dependencia de reset global. +1. Projetar DI em incremento separado. +2. Endurecer Dommel em design proprio, mantendo honestos os limites process-wide de `DommelMapper`. +3. Avaliar full benchmark antes de release. +4. Migrar gradualmente testes antigos de isolamento/concurrencia para runtime instanciado quando isso reduzir dependencia de reset global. ## Decisoes relevantes @@ -67,13 +72,16 @@ e preparando configuracoes imutaveis com runtime isolado. - `FluentMapper` delega ao runtime default para diagnostics e query helpers estaticos. - `Initialize` deve continuar aditivo inicialmente. - `Reset` nao e solucao arquitetural principal. +- `FluentMapper.Configuration` e `FluentMapper.Runtime` representam a configuracao/runtime default publicados. +- `ImmutableFluentMapConfiguration.CreateRuntime()` e o caminho ergonomico para criar runtime isolado. - DI deve registrar configuracao e runtime como singleton. - Dommel permanece bridge process-wide ate design especifico. - Native AOT nao deve ser prometido alem do que os smokes validam. ## Estado global identificado -- `FluentMapper._registry`. +- `FluentMapper._builderRegistry`. +- `FluentMapper._runtime`. - `FluentMapper._configuration`. - `FluentMapper.EntityMaps`. - `FluentMapper.TypeConventions`. @@ -135,6 +143,9 @@ e preparando configuracoes imutaveis com runtime isolado. - `.sdd/etapa-11/01-historical-configuration-issues.md` - `.sdd/etapa-11/02-configuration-isolation-spec.md` - `.sdd/etapa-11/03-configuration-model.md` +- `.sdd/etapa-11/04-isolated-runtime.md` +- `.sdd/etapa-11/05-performance-impact.md` +- `.sdd/etapa-11/06-compatibility-bridge.md` - `.sdd/etapa-11/DECISIONS.md` - `.sdd/etapa-11/STATUS.md` - `README.md` @@ -150,6 +161,7 @@ e preparando configuracoes imutaveis com runtime isolado. - `src/Dapper.FluentMap/QueryMappedExtensions.cs` - `test/Dapper.FluentMap.Tests/ImmutableConfigurationModelTests.cs` - `test/Dapper.FluentMap.Tests/IsolatedRuntimeTests.cs` +- `test/Dapper.FluentMap.Tests/CompatibilityBridgeTests.cs` - `benchmarks/Dapper.FluentMap.Benchmarks/Program.cs` ## Validacao do Prompt 11.1 @@ -180,6 +192,22 @@ e preparando configuracoes imutaveis com runtime isolado. - `dotnet test .\Dapper.FluentMap.sln --configuration Release --no-build`: sucesso, 429 testes aprovados. - `dotnet pack .\src\Dapper.FluentMap\Dapper.FluentMap.csproj --configuration Release --no-build --output .\artifacts\packages`: sucesso; criou `artifacts\packages\Dapper.FluentMap.2.0.0.nupkg`; warning existente `NU5125` sobre `licenseUrl` obsoleto. +## Validacao do Prompt 11.4 + +- Detectado runner de testes como VSTest: SDK `10.0.302`, sem `global.json`, sem `Directory.Build.props`/`Directory.Packages.props` e projetos de teste com `Microsoft.NET.Test.Sdk` + xUnit runner. +- `dotnet build .\src\Dapper.FluentMap\Dapper.FluentMap.csproj --configuration Release`: sucesso, 0 warnings, 0 errors. +- `dotnet build .\test\Dapper.FluentMap.Tests\Dapper.FluentMap.Tests.csproj --configuration Release`: sucesso, 0 warnings, 0 errors. +- `dotnet test .\test\Dapper.FluentMap.Tests\Dapper.FluentMap.Tests.csproj --configuration Release --no-build --filter "FullyQualifiedName~CompatibilityBridgeTests"`: sucesso, 5 testes aprovados. +- `dotnet build .\test\Dapper.FluentMap.Dommel.Tests\Dapper.FluentMap.Dommel.Tests.csproj --configuration Release`: sucesso, 0 warnings, 0 errors. +- `dotnet test .\test\Dapper.FluentMap.Dommel.Tests\Dapper.FluentMap.Dommel.Tests.csproj --configuration Release --no-build`: sucesso, 22 testes aprovados. +- `dotnet test .\test\Dapper.FluentMap.Tests\Dapper.FluentMap.Tests.csproj --configuration Release --no-build`: sucesso, 362 testes aprovados. +- `dotnet restore .\Dapper.FluentMap.sln`: sucesso. +- `dotnet build .\Dapper.FluentMap.sln --configuration Release --no-restore`: sucesso, 0 warnings, 0 errors. +- `dotnet test .\Dapper.FluentMap.sln --configuration Release --no-build`: sucesso, 434 testes aprovados no total. +- `dotnet pack .\src\Dapper.FluentMap\Dapper.FluentMap.csproj --configuration Release --no-build --output .\artifacts\packages`: sucesso; criou `artifacts\packages\Dapper.FluentMap.2.0.0.nupkg`; warning existente `NU5125` sobre `licenseUrl` obsoleto. +- Inspecionado `artifacts\packages\Dapper.FluentMap.2.0.0.nupkg`: contem nuspec/metadados e `lib/netstandard2.0/Dapper.FluentMap.dll` + XML; nao contem projetos de teste. +- Ferramenta dedicada de API compatibility nao foi encontrada no projeto atual; ha referencias planejadas para Etapa 12, mas sem `ApiCompat`, `PublicApiAnalyzers` ou package validation configurados nesta etapa. + ## Ultimo prompt executado -Ultimo prompt executado: 11.3 +Ultimo prompt executado: 11.4 diff --git a/README.md b/README.md index 6cbec8d..635a317 100644 --- a/README.md +++ b/README.md @@ -338,7 +338,7 @@ var configuration = new FluentMapConfigurationBuilder() .Configure(config => config.AddGeneratedMappings()) .Build(); -var runtime = new FluentMapRuntime(configuration); +var runtime = configuration.CreateRuntime(); ``` `Build()` validates the same invariants used by `FluentMapper.Validate()` and @@ -347,7 +347,9 @@ profiles, conventions, naming policies, persistence metadata, converters and generated materializer registrations. The builder is sealed after `Build()`. Create a `FluentMapRuntime` from the immutable configuration when multiple configuration-specific `QueryMapped*` pipelines must coexist in the same -process. `FluentMapper.Initialize(...)` remains the global compatibility layer. +process. `FluentMapper.Initialize(...)` remains the global compatibility layer, +with `FluentMapper.Configuration` and `FluentMapper.Runtime` exposing the +currently published default configuration and runtime. ## Conventions and Naming Policies @@ -593,7 +595,7 @@ var configuration = new FluentMapConfigurationBuilder() .AddMap() .Build(); -var runtime = new FluentMapRuntime(configuration); +var runtime = configuration.CreateRuntime(); var customers = runtime.QueryMapped( connection, "SELECT 7 AS customer_id, 'Ada' AS Name;"); @@ -1099,7 +1101,7 @@ var configuration = new FluentMapConfigurationBuilder() .Configure(config => config.AddGeneratedMappings()) .Build(); -var runtime = new FluentMapRuntime(configuration); +var runtime = configuration.CreateRuntime(); ``` `Build()` valida os mesmos invariants usados por `FluentMapper.Validate()` e @@ -1109,7 +1111,8 @@ generated materializer registrations. O builder fica selado depois de `Build()`. Crie um `FluentMapRuntime` a partir da configuracao imutavel quando multiplos pipelines `QueryMapped*` especificos por configuracao precisarem coexistir no mesmo processo. `FluentMapper.Initialize(...)` continua sendo a camada global de -compatibilidade. +compatibilidade, com `FluentMapper.Configuration` e `FluentMapper.Runtime` +expondo a configuracao e o runtime default publicados. ## Convenções e Políticas de Nomenclatura @@ -1355,7 +1358,7 @@ var configuration = new FluentMapConfigurationBuilder() .AddMap() .Build(); -var runtime = new FluentMapRuntime(configuration); +var runtime = configuration.CreateRuntime(); var customers = runtime.QueryMapped( connection, "SELECT 7 AS customer_id, 'Ada' AS Name;"); diff --git a/src/Dapper.FluentMap/Configuration/FluentConventionConfiguration.cs b/src/Dapper.FluentMap/Configuration/FluentConventionConfiguration.cs index b7156d1..76a5430 100644 --- a/src/Dapper.FluentMap/Configuration/FluentConventionConfiguration.cs +++ b/src/Dapper.FluentMap/Configuration/FluentConventionConfiguration.cs @@ -27,7 +27,7 @@ public class FluentConventionConfiguration /// /// The convention. public FluentConventionConfiguration(Convention convention) - : this(convention, FluentMapper.Registry, ensureMutable: null) + : this(convention, FluentMapper.ConfigurationRegistry, ensureMutable: null) { } diff --git a/src/Dapper.FluentMap/Configuration/FluentMapConfiguration.cs b/src/Dapper.FluentMap/Configuration/FluentMapConfiguration.cs index ac84ae3..e11d1af 100644 --- a/src/Dapper.FluentMap/Configuration/FluentMapConfiguration.cs +++ b/src/Dapper.FluentMap/Configuration/FluentMapConfiguration.cs @@ -26,7 +26,7 @@ public class FluentMapConfiguration /// Initializes a new instance of the class. /// public FluentMapConfiguration() - : this(FluentMapper.Registry, ensureMutable: null) + : this(FluentMapper.ConfigurationRegistry, ensureMutable: null) { } diff --git a/src/Dapper.FluentMap/Configuration/ImmutableFluentMapConfiguration.cs b/src/Dapper.FluentMap/Configuration/ImmutableFluentMapConfiguration.cs index f11775b..2525aec 100644 --- a/src/Dapper.FluentMap/Configuration/ImmutableFluentMapConfiguration.cs +++ b/src/Dapper.FluentMap/Configuration/ImmutableFluentMapConfiguration.cs @@ -51,6 +51,15 @@ private ImmutableFluentMapConfiguration( /// public IReadOnlyList GeneratedMaterializers { get; } + /// + /// Creates a runtime that uses this immutable configuration and owns its derived caches. + /// + /// A FluentMap runtime bound to this configuration. + public FluentMapRuntime CreateRuntime() + { + return new FluentMapRuntime(this); + } + internal static ImmutableFluentMapConfiguration Create(MappingRegistry registry) { if (registry == null) diff --git a/src/Dapper.FluentMap/FluentMapper.cs b/src/Dapper.FluentMap/FluentMapper.cs index a07291c..51e72c0 100644 --- a/src/Dapper.FluentMap/FluentMapper.cs +++ b/src/Dapper.FluentMap/FluentMapper.cs @@ -2,10 +2,12 @@ using System.Collections.Concurrent; using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; +using System.Linq; using Dapper.FluentMap.Configuration; using Dapper.FluentMap.Conventions; using Dapper.FluentMap.Diagnostics; using Dapper.FluentMap.Mapping; +using Dapper.FluentMap.TypeMaps; namespace Dapper.FluentMap { @@ -18,9 +20,10 @@ public static class FluentMapper DynamicallyAccessedMemberTypes.PublicConstructors | DynamicallyAccessedMemberTypes.PublicProperties; - private static readonly MappingRegistry _registry = new MappingRegistry(); - private static readonly FluentMapRuntime _runtime = new FluentMapRuntime(_registry); - private static readonly FluentMapConfiguration _configuration = new FluentMapConfiguration(); + private static readonly object _syncRoot = new object(); + private static readonly MappingRegistry _builderRegistry = new MappingRegistry(installDapperTypeMaps: false); + private static readonly FluentMapConfiguration _configuration = new FluentMapConfiguration(_builderRegistry, ensureMutable: null); + private static volatile FluentMapRuntime _runtime = CreateRuntime(_builderRegistry); /// /// Gets the dictionary containing the entity mapping per entity type. @@ -30,7 +33,7 @@ public static class FluentMapper /// through and use /// for read-only inspection. /// - public static readonly ConcurrentDictionary EntityMaps = _registry.EntityMaps; + public static readonly ConcurrentDictionary EntityMaps = _builderRegistry.EntityMaps; /// /// Gets the dictionary containing the conventions per entity type. @@ -40,11 +43,21 @@ public static class FluentMapper /// through and use /// for read-only inspection. /// - public static readonly ConcurrentDictionary> TypeConventions = _registry.TypeConventions; + public static readonly ConcurrentDictionary> TypeConventions = _builderRegistry.TypeConventions; - internal static MappingRegistry Registry => _registry; + /// + /// Gets the immutable configuration currently used by the default compatibility runtime. + /// + public static ImmutableFluentMapConfiguration Configuration => Runtime.Configuration; + + /// + /// Gets the default compatibility runtime used by the historical static APIs. + /// + public static FluentMapRuntime Runtime => _runtime; + + internal static MappingRegistry Registry => Runtime.Registry; - internal static FluentMapRuntime Runtime => _runtime; + internal static MappingRegistry ConfigurationRegistry => _builderRegistry; /// /// Initializes Dapper.FluentMap with the specified configuration. @@ -53,7 +66,24 @@ public static class FluentMapper /// A callback containing the configuration of Dapper.FluentMap. public static void Initialize(Action configure) { - configure(_configuration); + if (configure == null) + { + throw new ArgumentNullException(nameof(configure)); + } + + lock (_syncRoot) + { + try + { + configure(_configuration); + PublishDefaultRuntime(); + } + catch + { + PublishDefaultRuntime(); + throw; + } + } } /// @@ -64,7 +94,8 @@ public static void Initialize(Action configure) /// public static void Validate() { - _runtime.Validate(); + _builderRegistry.ValidateConfiguration(); + Runtime.Validate(); } /// @@ -73,7 +104,7 @@ public static void Validate() /// A read-only snapshot of the registered default entity maps. public static IReadOnlyDictionary GetEntityMaps() { - return _registry.GetEntityMapsSnapshot(); + return _builderRegistry.GetEntityMapsSnapshot(); } /// @@ -82,7 +113,7 @@ public static IReadOnlyDictionary GetEntityMaps() /// A read-only snapshot of the registered type conventions. public static IReadOnlyDictionary> GetTypeConventions() { - return _registry.GetTypeConventionsSnapshot(); + return _builderRegistry.GetTypeConventionsSnapshot(); } /// @@ -94,7 +125,7 @@ public static MappingExplanation Explain< [DynamicallyAccessedMembers(EntityMemberTypes)] TEntity>() { - return _runtime.Explain(); + return Runtime.Explain(); } /// @@ -109,7 +140,7 @@ public static MappingExplanation Explain< TProfile>() where TProfile : IMappingProfile { - return _runtime.Explain(); + return Runtime.Explain(); } /// @@ -118,7 +149,7 @@ public static MappingExplanation Explain< /// The type of the entity. internal static void AddTypeMap() { - _registry.ResetDapperTypeMap(); + SetDapperTypeMap(typeof(TEntity)); } /// @@ -127,7 +158,7 @@ internal static void AddTypeMap() /// The type of the entity. internal static void AddTypeMap(Type entityType) { - _registry.ResetDapperTypeMap(entityType); + SetDapperTypeMap(entityType); } /// @@ -150,7 +181,49 @@ internal static void AddConventionTypeMap(Type entityType) internal static void Reset(params Type[] dapperTypes) { - _registry.Reset(dapperTypes); + lock (_syncRoot) + { + _builderRegistry.Reset(dapperTypes); + _runtime = CreateRuntime(_builderRegistry); + } + } + + private static FluentMapRuntime CreateRuntime(MappingRegistry registry) + { + var configuration = ImmutableFluentMapConfiguration.Create(registry); + return configuration.CreateRuntime(); + } + + private static void PublishDefaultRuntime() + { + var runtime = CreateRuntime(_builderRegistry); + _runtime = runtime; + InstallDefaultDapperTypeMaps(runtime.Configuration); + } + + private static void InstallDefaultDapperTypeMaps(ImmutableFluentMapConfiguration configuration) + { + foreach (var entityType in GetDefaultDapperMappedTypes(configuration)) + { + SetDapperTypeMap(entityType); + } + } + + private static IEnumerable GetDefaultDapperMappedTypes(ImmutableFluentMapConfiguration configuration) + { + return configuration.EntityMaps.Keys + .Concat(configuration.TypeConventions.Keys) + .Distinct(); + } + + private static void SetDapperTypeMap(Type entityType) + { + if (entityType == null) + { + throw new ArgumentNullException(nameof(entityType)); + } + + SqlMapper.SetTypeMap(entityType, new FluentMapTypeMap(entityType)); } } } diff --git a/test/Dapper.FluentMap.Dommel.Tests/DommelPersistenceIntegrationTests.cs b/test/Dapper.FluentMap.Dommel.Tests/DommelPersistenceIntegrationTests.cs index cf8e231..2df4f39 100644 --- a/test/Dapper.FluentMap.Dommel.Tests/DommelPersistenceIntegrationTests.cs +++ b/test/Dapper.FluentMap.Dommel.Tests/DommelPersistenceIntegrationTests.cs @@ -339,6 +339,7 @@ private static void PreTest() { FluentMapper.EntityMaps.Clear(); FluentMapper.TypeConventions.Clear(); + FluentMapper.Initialize(_ => { }); DommelMapper.LogReceived = null; } diff --git a/test/Dapper.FluentMap.Dommel.Tests/HistoricalRegression/DommelHistoricalRegressionTests.cs b/test/Dapper.FluentMap.Dommel.Tests/HistoricalRegression/DommelHistoricalRegressionTests.cs index cd149a9..37879e7 100644 --- a/test/Dapper.FluentMap.Dommel.Tests/HistoricalRegression/DommelHistoricalRegressionTests.cs +++ b/test/Dapper.FluentMap.Dommel.Tests/HistoricalRegression/DommelHistoricalRegressionTests.cs @@ -250,6 +250,7 @@ private static void PreTest() { FluentMapper.EntityMaps.Clear(); FluentMapper.TypeConventions.Clear(); + FluentMapper.Initialize(_ => { }); DommelMapper.LogReceived = null; } diff --git a/test/Dapper.FluentMap.Dommel.Tests/ManualMappingTests.cs b/test/Dapper.FluentMap.Dommel.Tests/ManualMappingTests.cs index a889501..049212e 100644 --- a/test/Dapper.FluentMap.Dommel.Tests/ManualMappingTests.cs +++ b/test/Dapper.FluentMap.Dommel.Tests/ManualMappingTests.cs @@ -220,6 +220,7 @@ private static void PreTest() { FluentMapper.EntityMaps.Clear(); FluentMapper.TypeConventions.Clear(); + FluentMapper.Initialize(_ => { }); } private class MapWithCustomIdPropertyMap : DommelEntityMap diff --git a/test/Dapper.FluentMap.Tests/CompatibilityBridgeTests.cs b/test/Dapper.FluentMap.Tests/CompatibilityBridgeTests.cs new file mode 100644 index 0000000..c2078ab --- /dev/null +++ b/test/Dapper.FluentMap.Tests/CompatibilityBridgeTests.cs @@ -0,0 +1,295 @@ +using System; +using System.Linq; +using System.Threading.Tasks; +using Dapper.FluentMap.Configuration; +using Dapper.FluentMap.Mapping; +using Dapper.FluentMap.Materialization; +using Microsoft.Data.Sqlite; +using Xunit; + +namespace Dapper.FluentMap.Tests +{ + public class CompatibilityBridgeTests + { + [Fact] + [Trait("Category", "Integration")] + public void StaticAndConfigurationAwareQueriesShouldProduceSameResult() + { + ResetMapper(typeof(BridgeCustomer)); + + try + { + FluentMapper.Initialize(configuration => configuration.AddMap(new BridgeCustomerMap())); + var runtime = FluentMapper.Configuration.CreateRuntime(); + + using (var connection = OpenConnection()) + { + var legacy = connection.QueryMappedSingle( + "SELECT 1 AS bridge_id, 'Ada' AS bridge_name;"); + var isolated = runtime.QueryMappedSingle( + connection, + "SELECT 1 AS bridge_id, 'Ada' AS bridge_name;"); + + Assert.Equal(legacy.Id, isolated.Id); + Assert.Equal(legacy.Name, isolated.Name); + Assert.Same(FluentMapper.Configuration, FluentMapper.Runtime.Configuration); + } + } + finally + { + ResetMapper(typeof(BridgeCustomer)); + } + } + + [Fact] + public void RepeatedInitializeShouldPublishAdditiveDefaultConfiguration() + { + ResetMapper(typeof(FirstBridgeEntity), typeof(SecondBridgeEntity)); + + try + { + FluentMapper.Initialize(configuration => configuration.AddMap(new FirstBridgeMap())); + var firstRuntime = FluentMapper.Runtime; + + FluentMapper.Initialize(configuration => configuration.AddMap(new SecondBridgeMap())); + + Assert.NotSame(firstRuntime, FluentMapper.Runtime); + Assert.True(FluentMapper.Configuration.EntityMaps.ContainsKey(typeof(FirstBridgeEntity))); + Assert.True(FluentMapper.Configuration.EntityMaps.ContainsKey(typeof(SecondBridgeEntity))); + Assert.IsType(FluentMapper.EntityMaps[typeof(FirstBridgeEntity)]); + Assert.IsType(FluentMapper.EntityMaps[typeof(SecondBridgeEntity)]); + } + finally + { + ResetMapper(typeof(FirstBridgeEntity), typeof(SecondBridgeEntity)); + } + } + + [Fact] + public void ConcurrentInitializeShouldBeSerializedForDefaultConfiguration() + { + ResetMapper(typeof(ConcurrentFirstBridgeEntity), typeof(ConcurrentSecondBridgeEntity)); + + try + { + Parallel.Invoke( + () => FluentMapper.Initialize(configuration => configuration.AddMap(new ConcurrentFirstBridgeMap())), + () => FluentMapper.Initialize(configuration => configuration.AddMap(new ConcurrentSecondBridgeMap()))); + + FluentMapper.Validate(); + Assert.True(FluentMapper.Configuration.EntityMaps.ContainsKey(typeof(ConcurrentFirstBridgeEntity))); + Assert.True(FluentMapper.Configuration.EntityMaps.ContainsKey(typeof(ConcurrentSecondBridgeEntity))); + } + finally + { + ResetMapper(typeof(ConcurrentFirstBridgeEntity), typeof(ConcurrentSecondBridgeEntity)); + } + } + + [Fact] + [Trait("Category", "Integration")] + public void LegacyMutableDictionaryChangesShouldNotRewritePublishedRuntime() + { + ResetMapper(typeof(DictionaryBridgeEntity)); + + try + { + FluentMapper.EntityMaps.TryAdd(typeof(DictionaryBridgeEntity), new DictionaryBridgeMap()); + + using (var connection = OpenConnection()) + { + var beforePublish = connection.QueryMappedSingle( + "SELECT 5 AS dictionary_id;"); + + FluentMapper.Initialize(_ => { }); + + var afterPublish = connection.QueryMappedSingle( + "SELECT 5 AS dictionary_id;"); + + Assert.Equal(0, beforePublish.Id); + Assert.Equal(5, afterPublish.Id); + } + } + finally + { + ResetMapper(typeof(DictionaryBridgeEntity)); + } + } + + [Fact] + [Trait("Category", "Integration")] + public void DefaultBridgeShouldPreserveProfilesConvertersAndGeneratedMaterializers() + { + ResetMapper(typeof(BridgeConversionCustomer)); + + try + { + FluentMapper.Initialize(configuration => + { + configuration.AddMap(new BridgeConversionMap()); + configuration.AddProfile(); + configuration.AddGeneratedMaterializer( + new[] + { + GeneratedMaterializerColumn.Map("bridge_id", nameof(BridgeConversionCustomer.Id)), + GeneratedMaterializerColumn.Map("bridge_name", nameof(BridgeConversionCustomer.Name)) + }, + record => new BridgeConversionCustomer + { + Id = Convert.ToInt32(record.GetValue(0)), + Name = "generated:" + Convert.ToString(record.GetValue(1)) + }); + }); + + using (var connection = OpenConnection()) + { + var generated = connection.QueryMappedSingle( + "SELECT 7 AS bridge_id, 'ada' AS bridge_name;"); + var profile = connection.QueryMappedSingle( + "SELECT 8 AS profile_id, 'grace' AS profile_name;"); + + Assert.Equal("generated:ada", generated.Name); + Assert.Equal("GRACE", profile.Name); + Assert.Equal(1, FluentMapper.Runtime.GeneratedMaterializerCount); + } + } + finally + { + ResetMapper(typeof(BridgeConversionCustomer)); + } + } + + 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 BridgeCustomer + { + public int Id { get; set; } + + public string Name { get; set; } + } + + private sealed class BridgeCustomerMap : EntityMap + { + public BridgeCustomerMap() + { + Map(customer => customer.Id).ToColumn("bridge_id"); + Map(customer => customer.Name).ToColumn("bridge_name"); + } + } + + private sealed class FirstBridgeEntity + { + public int Id { get; set; } + } + + private sealed class FirstBridgeMap : EntityMap + { + public FirstBridgeMap() + { + Map(entity => entity.Id).ToColumn("first_id"); + } + } + + private sealed class SecondBridgeEntity + { + public int Id { get; set; } + } + + private sealed class SecondBridgeMap : EntityMap + { + public SecondBridgeMap() + { + Map(entity => entity.Id).ToColumn("second_id"); + } + } + + private sealed class ConcurrentFirstBridgeEntity + { + public int Id { get; set; } + } + + private sealed class ConcurrentFirstBridgeMap : EntityMap + { + public ConcurrentFirstBridgeMap() + { + Map(entity => entity.Id).ToColumn("first_concurrent_id"); + } + } + + private sealed class ConcurrentSecondBridgeEntity + { + public int Id { get; set; } + } + + private sealed class ConcurrentSecondBridgeMap : EntityMap + { + public ConcurrentSecondBridgeMap() + { + Map(entity => entity.Id).ToColumn("second_concurrent_id"); + } + } + + private sealed class DictionaryBridgeEntity + { + public int Id { get; set; } + } + + private sealed class DictionaryBridgeMap : EntityMap + { + public DictionaryBridgeMap() + { + Map(entity => entity.Id).ToColumn("dictionary_id"); + } + } + + private sealed class BridgeProfile : IMappingProfile + { + } + + private sealed class BridgeConversionCustomer + { + public int Id { get; set; } + + public string Name { get; set; } + } + + private sealed class BridgeConversionMap : EntityMap + { + public BridgeConversionMap() + { + Map(customer => customer.Id).ToColumn("bridge_id"); + Map(customer => customer.Name).ToColumn("bridge_name"); + } + } + + private sealed class BridgeProfileMap : + EntityMap, + IProfileMap + { + public BridgeProfileMap() + { + Map(customer => customer.Id).ToColumn("profile_id"); + Map(customer => customer.Name).ToColumn("profile_name") + .ConvertFromDatabaseUsing(); + } + } + + private sealed class UpperBridgeNameConverter : IReadPropertyConverter + { + public string ConvertFromDatabase(string value) + { + return value.ToUpperInvariant(); + } + } + } +} diff --git a/test/Dapper.FluentMap.Tests/IsolatedRuntimeTests.cs b/test/Dapper.FluentMap.Tests/IsolatedRuntimeTests.cs index 8103ee8..52d1bd2 100644 --- a/test/Dapper.FluentMap.Tests/IsolatedRuntimeTests.cs +++ b/test/Dapper.FluentMap.Tests/IsolatedRuntimeTests.cs @@ -267,7 +267,7 @@ private static FluentMapRuntime CreateRuntime(Action Date: Wed, 29 Jul 2026 09:49:01 -0300 Subject: [PATCH 33/49] feat(di): add FluentMap dependency injection integration --- .sdd/etapa-11/07-dependency-injection-spec.md | 261 ++++++++++++++++++ .sdd/etapa-11/DECISIONS.md | 79 ++++++ .sdd/etapa-11/STATUS.md | 48 +++- Dapper.FluentMap.sln | 14 + README.md | 101 +++++++ ...apper.FluentMap.DependencyInjection.csproj | 23 ++ .../FluentMapServiceCollectionExtensions.cs | 46 +++ .../README.md | 21 ++ .../Dapper.FluentMap.AotSmoke.csproj | 9 +- test/Dapper.FluentMap.AotSmoke/Program.cs | 85 +++++- ...FluentMap.DependencyInjection.Tests.csproj | 18 ++ ...uentMapServiceCollectionExtensionsTests.cs | 260 +++++++++++++++++ ...uentMap.GeneratedRegistration.Tests.csproj | 2 + .../GeneratedRegistrationIntegrationTests.cs | 25 ++ 14 files changed, 984 insertions(+), 8 deletions(-) create mode 100644 .sdd/etapa-11/07-dependency-injection-spec.md create mode 100644 src/Dapper.FluentMap.DependencyInjection/Dapper.FluentMap.DependencyInjection.csproj create mode 100644 src/Dapper.FluentMap.DependencyInjection/FluentMapServiceCollectionExtensions.cs create mode 100644 src/Dapper.FluentMap.DependencyInjection/README.md create mode 100644 test/Dapper.FluentMap.DependencyInjection.Tests/Dapper.FluentMap.DependencyInjection.Tests.csproj create mode 100644 test/Dapper.FluentMap.DependencyInjection.Tests/FluentMapServiceCollectionExtensionsTests.cs diff --git a/.sdd/etapa-11/07-dependency-injection-spec.md b/.sdd/etapa-11/07-dependency-injection-spec.md new file mode 100644 index 0000000..1c3963d --- /dev/null +++ b/.sdd/etapa-11/07-dependency-injection-spec.md @@ -0,0 +1,261 @@ +# Dependency Injection Integration Specification + +## Objetivo + +Adicionar uma integracao oficial pequena com +`Microsoft.Extensions.DependencyInjection` para ASP.NET Core, Worker Services, +generic host e aplicacoes modulares, sem tornar DI obrigatoria para o pacote +core. + +## Package/project location + +Decisao implementada: + +```text +src/Dapper.FluentMap.DependencyInjection/ +test/Dapper.FluentMap.DependencyInjection.Tests/ +``` + +PackageId: + +```text +Dapper.FluentMap.DependencyInjection +``` + +O pacote separado mantem `Dapper.FluentMap` livre de dependencia em +`Microsoft.Extensions.*` para consumidores que usam somente a API estatica, +runtime manual ou outros containers. + +## Dependencies + +O pacote de DI: + +- referencia `Dapper.FluentMap`; +- depende de `Microsoft.Extensions.DependencyInjection.Abstractions`; +- nao depende de `Microsoft.Extensions.DependencyInjection` runtime; +- nao depende de ASP.NET Core, Hosting, Options, Logging ou Dommel. + +`Microsoft.Extensions.DependencyInjection.Abstractions` foi escolhido porque +expoe `IServiceCollection` e os descriptors/lifetimes necessarios. A versao +10.0.10 suporta `netstandard2.0`, preservando a compatibilidade do pacote novo +com o target do core. + +## Registration API + +API publica: + +```csharp +services.AddFluentMap(builder => +{ + builder.AddMap(); + builder.Configure(config => config.AddGeneratedMappings()); +}); +``` + +Contrato: + +- cria um `FluentMapConfigurationBuilder` local; +- executa o callback de configuracao uma vez; +- chama `Build()` uma vez; +- cria um `FluentMapRuntime` a partir do snapshot; +- chama `runtime.Validate()`; +- registra `ImmutableFluentMapConfiguration` e `FluentMapRuntime`; +- retorna o mesmo `IServiceCollection`. + +A API nao recebe `IServiceProvider` no callback. Isso evita service locator e +mantem a configuracao como parte da composition root. Conversores que precisam +de estado externo continuam devendo ser configurados explicitamente por +instancia/delegate ou tratados por design futuro. + +## Lifetime + +Registros: + +```text +ImmutableFluentMapConfiguration -> Singleton +FluentMapRuntime -> Singleton +``` + +Justificativa: + +- o builder e mutavel e vive apenas durante o `AddFluentMap`; +- `Build()` produz snapshot imutavel e read-only; +- `FluentMapRuntime` possui caches derivados por configuracao, usa colecoes + concorrentes no hot path e nao possui conexao, transacao, comando, reader ou + estado por query; +- queries concorrentes usando o mesmo runtime ja sao suportadas pelos testes do + runtime isolado e passam a ser cobertas no pacote DI. + +Nao foram adicionados services scoped/transient porque o FluentMap nao possui +recurso por request. Aplicacoes podem registrar wrappers proprios quando +combinarem runtime com conexoes, tenants ou servicos scoped. + +## Named/keyed/multiple configurations + +O prompt 11.5 nao introduz named options nem keyed services. + +Motivos: + +- o pacote mira `netstandard2.0`; keyed services sao recurso moderno do + ecossistema DI e exigiriam aumento de TFM ou dependencia condicional; +- nao ha ainda contrato publico de selecao nomeada de runtime no FluentMap; +- adicionar nomes agora criaria uma API dificil de versionar sem necessidade + comprovada. + +Suporte atual: + +- uma configuracao default por `IServiceCollection`; +- multiplas configuracoes por multiplos `ServiceProvider`/composition roots; +- multiplas configuracoes manuais via + `FluentMapConfigurationBuilder -> Build() -> CreateRuntime()`. + +Evolucao futura permanece possivel por overloads adicionais, por exemplo +registro keyed/named em TFM moderno, sem quebrar `AddFluentMap(...)`. + +## Startup validation + +`AddFluentMap(...)` valida imediatamente durante composicao: + +```text +configure(builder) + -> builder.Build() + -> configuration.CreateRuntime() + -> runtime.Validate() +``` + +Essa escolha segue fail-fast sem depender da primeira resolucao do container. +Ela tambem evita que um `ServiceProvider` seja construido com metadata invalida +que so falharia na primeira query. + +## Assembly scanning + +Assembly scanning nao e obrigatorio no caminho DI. + +O callback aceita toda a DSL do `FluentMapConfigurationBuilder`, entao scanning +continua possivel: + +```csharp +services.AddFluentMap(builder => +{ + builder.AddMapsFromAssemblyContaining(); +}); +``` + +Mas o caminho preferido para trimming/Native AOT e registro explicito ou +gerado. A API de DI nao adiciona scanning automatico por assembly de entrada, +marker type, AppDomain ou service collection. + +## Generated registration + +O source generator atual emite `AddGeneratedMappings()` como extensao sobre +`FluentMapConfiguration`. O builder preserva esse caminho por +`Configure(...)`: + +```csharp +services.AddFluentMap(builder => +{ + builder.Configure(config => config.AddGeneratedMappings()); +}); +``` + +Generated registration continua sendo build-time para descoberta dos maps da +compilacao atual. No pacote DI, ele e apenas mais uma entrada explicita para o +builder; os descriptors gerados sao congelados no snapshot e associados ao +runtime singleton. + +## Trimming + +O pacote DI em si nao faz scanning nem ativacao reflection-only. Ele delega ao +builder escolhido pelo usuario: + +- `AddMap()`: caminho preferido para trimmed apps; +- `Configure(config => config.AddGeneratedMappings())`: caminho preferido + quando o source generator for adotado; +- `AddMapsFromAssembly*`: continua anotado como trimming-sensitive no core. + +O pacote DI nao remove nem esconde os warnings do core. Quando o callback chama +uma API anotada, o aviso deve continuar aparecendo no call site do consumidor. + +## Native AOT + +Nao foi declarada compatibilidade Native AOT completa. + +O pacote DI pode participar de composicao AOT-friendly quando o consumidor usa +registro explicito ou gerado. Ainda assim: + +- `QueryMapped*` mantem anotacoes de trimming/dynamic-code porque pode cair no + fallback runtime; +- assembly scanning nao e recomendado; +- o smoke AOT existente deve ser estendido futuramente para cobrir o pacote DI + publicado, especialmente com generated registration. + +## ASP.NET Core examples + +Minimal hosting: + +```csharp +var builder = WebApplication.CreateBuilder(args); + +builder.Services.AddFluentMap(mapBuilder => +{ + mapBuilder.AddMap(); +}); + +var app = builder.Build(); +``` + +Worker/generic host: + +```csharp +Host.CreateDefaultBuilder(args) + .ConfigureServices(services => + { + services.AddFluentMap(builder => + { + builder.Configure(config => config.AddGeneratedMappings()); + }); + }); +``` + +Consumer service: + +```csharp +public sealed class CustomerReader +{ + private readonly FluentMapRuntime _runtime; + + public CustomerReader(FluentMapRuntime runtime) + { + _runtime = runtime; + } + + public Customer Read(IDbConnection connection) + { + return _runtime.QueryMappedSingle( + connection, + "SELECT 7 AS customer_id, 'Ada' AS name;"); + } +} +``` + +O pacote nao registra `IDbConnection`, connection factory, repositories ou unit +of work. Esses lifetimes pertencem a aplicacao. + +## Tests + +Cobertura adicionada: + +- registro de `ImmutableFluentMapConfiguration` e `FluentMapRuntime`; +- resolucao de services; +- identidade singleton; +- configuracao invalida com fail-fast; +- registro explicito por tipo; +- registro explicito por instancia; +- profiles; +- multiplos service providers com configuracoes independentes; +- queries concorrentes usando o runtime singleton; +- generated registration real via `AddGeneratedMappings()` no projeto de testes + do source generator. + +Nao foram criados testes ASP.NET completos porque `ServiceCollection` e +suficiente para validar contrato de registro, lifetimes e resolucao. diff --git a/.sdd/etapa-11/DECISIONS.md b/.sdd/etapa-11/DECISIONS.md index 4db5930..53f4618 100644 --- a/.sdd/etapa-11/DECISIONS.md +++ b/.sdd/etapa-11/DECISIONS.md @@ -310,3 +310,82 @@ O core avanca para runtime isolado sem transformar Dommel em parte da configuracao imutavel do core. Multiplas configuracoes Dommel no mesmo processo continuam fora do contrato ate design especifico dos extension points globais de `DommelMapper`. + +## ADR-16 - Dependency Injection package boundary + +### Contexto + +O FluentMap agora possui `ImmutableFluentMapConfiguration` e +`FluentMapRuntime`, que sao naturais para registro em DI. O core, porem, +continua sendo biblioteca publica de mapeamento e deve permanecer utilizavel sem +`Microsoft.Extensions.*`. + +### Decisao + +Criar um pacote separado `Dapper.FluentMap.DependencyInjection`, com projeto em +`src/Dapper.FluentMap.DependencyInjection`, dependente apenas de +`Dapper.FluentMap` e `Microsoft.Extensions.DependencyInjection.Abstractions`. + +### Alternativas consideradas + +- Colocar `AddFluentMap` diretamente no core. +- Criar integracao ASP.NET Core/Hosting mais ampla. +- Registrar conexoes, repositories ou Dommel junto com FluentMap. + +### Consequencias + +Consumidores que nao usam DI nao recebem dependencia nova. A descoberta por +NuGet permanece clara e o versionamento pode acompanhar o core sem misturar +dependencias opcionais. O pacote DI continua pequeno e nao torna DI obrigatoria. + +## ADR-17 - Dependency Injection registration and lifetimes + +### Contexto + +Configuracao e runtime sao imutaveis/thread-safe depois de construidos. O +runtime possui caches por configuracao, mas nao possui estado por query nem +recursos descartaveis. + +### Decisao + +`AddFluentMap(...)` constroi e valida a configuracao imediatamente, cria o +runtime e registra `ImmutableFluentMapConfiguration` e `FluentMapRuntime` como +singletons. + +### Alternativas consideradas + +- Construir a configuracao somente na primeira resolucao. +- Registrar runtime scoped por request. +- Aceitar callback com `IServiceProvider`. + +### Consequencias + +Falhas de configuracao aparecem durante startup/composition root. Caches sao +amortizados por aplicacao. A API evita service locator e nao cria dependencia +entre maps e services scoped. + +## ADR-18 - No named/keyed DI configurations in 11.5 + +### Contexto + +Aplicacoes modulares podem precisar de mais de uma configuracao, mas ainda nao +ha contrato publico de selecao nomeada de runtime. Keyed services exigiriam +TFMs/dependencias modernas que aumentariam o escopo. + +### Decisao + +Nao introduzir named options nem keyed services no prompt 11.5. A API registra +uma configuracao default por composition root. Multiplas configuracoes seguem +suportadas por service providers independentes ou construcao manual de +`FluentMapRuntime`. + +### Alternativas consideradas + +- Overloads `AddFluentMap(name, ...)`. +- Keyed services condicionais para .NET 8+. +- `IOptionsMonitor`/named options. + +### Consequencias + +A superficie inicial permanece pequena e compativel com `netstandard2.0`. A +evolucao futura pode adicionar overloads sem quebrar `AddFluentMap(...)`. diff --git a/.sdd/etapa-11/STATUS.md b/.sdd/etapa-11/STATUS.md index 8fd67c2..5ab06e2 100644 --- a/.sdd/etapa-11/STATUS.md +++ b/.sdd/etapa-11/STATUS.md @@ -49,6 +49,15 @@ e preparando configuracoes imutaveis com runtime isolado. - `GetEntityMaps()` e `GetTypeConventions()` continuam retornando snapshots das colecoes historicas registradas, preservando instancias de maps/conventions. - Dommel foi mantido como bridge process-wide sobre as colecoes legadas porque depende de metadata especifica de `DommelEntityMap` e `DommelPropertyMap`. - Criados testes de compatibility bridge para runtime default, API configuration-aware, equivalencia legacy/new, repeated Initialize, Initialize concorrente, colecoes legadas, generated materializers, profiles e converters. +- Criado `07-dependency-injection-spec.md`. +- Criado projeto `Dapper.FluentMap.DependencyInjection` em pacote separado. +- Implementado `services.AddFluentMap(builder => ...)`. +- `AddFluentMap(...)` constroi e valida a configuracao imediatamente. +- `ImmutableFluentMapConfiguration` e `FluentMapRuntime` sao registrados como singletons. +- O pacote DI depende de `Microsoft.Extensions.DependencyInjection.Abstractions` e nao adiciona dependencias de Hosting, Options, ASP.NET Core, Dommel ou runtime DI concreto. +- Criados testes de registration, service resolution, singleton identity, invalid config, explicit registration, profiles, multiple service providers, independent configurations e concurrency. +- Adicionado teste de generated registration via DI no projeto do source generator. +- `README.md` atualizado com instalacao e uso de `Dapper.FluentMap.DependencyInjection`. ## Em andamento @@ -56,10 +65,10 @@ e preparando configuracoes imutaveis com runtime isolado. ## Proximos passos -1. Projetar DI em incremento separado. -2. Endurecer Dommel em design proprio, mantendo honestos os limites process-wide de `DommelMapper`. -3. Avaliar full benchmark antes de release. -4. Migrar gradualmente testes antigos de isolamento/concurrencia para runtime instanciado quando isso reduzir dependencia de reset global. +1. Endurecer Dommel em design proprio, mantendo honestos os limites process-wide de `DommelMapper`. +2. Avaliar full benchmark antes de release. +3. Migrar gradualmente testes antigos de isolamento/concurrencia para runtime instanciado quando isso reduzir dependencia de reset global. +4. Estender smoke Native AOT para cobrir o pacote DI publicado com registro gerado, se a matriz de release exigir esse contrato. ## Decisoes relevantes @@ -75,6 +84,9 @@ e preparando configuracoes imutaveis com runtime isolado. - `FluentMapper.Configuration` e `FluentMapper.Runtime` representam a configuracao/runtime default publicados. - `ImmutableFluentMapConfiguration.CreateRuntime()` e o caminho ergonomico para criar runtime isolado. - DI deve registrar configuracao e runtime como singleton. +- DI fica em pacote separado `Dapper.FluentMap.DependencyInjection`. +- `AddFluentMap(...)` faz fail-fast em configuracao invalida. +- Named/keyed configurations nao foram adicionadas no prompt 11.5. - Dommel permanece bridge process-wide ate design especifico. - Native AOT nao deve ser prometido alem do que os smokes validam. @@ -162,6 +174,11 @@ e preparando configuracoes imutaveis com runtime isolado. - `test/Dapper.FluentMap.Tests/ImmutableConfigurationModelTests.cs` - `test/Dapper.FluentMap.Tests/IsolatedRuntimeTests.cs` - `test/Dapper.FluentMap.Tests/CompatibilityBridgeTests.cs` +- `.sdd/etapa-11/07-dependency-injection-spec.md` +- `src/Dapper.FluentMap.DependencyInjection/Dapper.FluentMap.DependencyInjection.csproj` +- `src/Dapper.FluentMap.DependencyInjection/FluentMapServiceCollectionExtensions.cs` +- `test/Dapper.FluentMap.DependencyInjection.Tests/Dapper.FluentMap.DependencyInjection.Tests.csproj` +- `test/Dapper.FluentMap.DependencyInjection.Tests/FluentMapServiceCollectionExtensionsTests.cs` - `benchmarks/Dapper.FluentMap.Benchmarks/Program.cs` ## Validacao do Prompt 11.1 @@ -208,6 +225,27 @@ e preparando configuracoes imutaveis com runtime isolado. - Inspecionado `artifacts\packages\Dapper.FluentMap.2.0.0.nupkg`: contem nuspec/metadados e `lib/netstandard2.0/Dapper.FluentMap.dll` + XML; nao contem projetos de teste. - Ferramenta dedicada de API compatibility nao foi encontrada no projeto atual; ha referencias planejadas para Etapa 12, mas sem `ApiCompat`, `PublicApiAnalyzers` ou package validation configurados nesta etapa. +## Validacao do Prompt 11.5 + +- Detectado runner de testes como VSTest: SDK `10.0.302`, sem `global.json`, sem `Directory.Build.props`/`Directory.Packages.props` e projetos de teste com `Microsoft.NET.Test.Sdk` + xUnit runner. +- `dotnet restore .\Dapper.FluentMap.sln`: sucesso. +- `dotnet build .\src\Dapper.FluentMap.DependencyInjection\Dapper.FluentMap.DependencyInjection.csproj --configuration Release --no-restore`: sucesso, 0 warnings, 0 errors. +- `dotnet build .\test\Dapper.FluentMap.DependencyInjection.Tests\Dapper.FluentMap.DependencyInjection.Tests.csproj --configuration Release --no-restore`: sucesso, 0 warnings, 0 errors. +- `dotnet build .\test\Dapper.FluentMap.GeneratedRegistration.Tests\Dapper.FluentMap.GeneratedRegistration.Tests.csproj --configuration Release --no-restore`: sucesso, 0 warnings, 0 errors. +- `dotnet test .\test\Dapper.FluentMap.DependencyInjection.Tests\Dapper.FluentMap.DependencyInjection.Tests.csproj --configuration Release --no-build`: sucesso, 8 testes aprovados. +- `dotnet test .\test\Dapper.FluentMap.GeneratedRegistration.Tests\Dapper.FluentMap.GeneratedRegistration.Tests.csproj --configuration Release --no-build --filter "FullyQualifiedName~GeneratedRegistrationShouldWorkThroughDependencyInjection"`: sucesso, 1 teste aprovado. +- `dotnet run --project .\test\Dapper.FluentMap.AotSmoke\Dapper.FluentMap.AotSmoke.csproj --configuration Release -p:DefineConstants=AOT_SMOKE_DI_EXPLICIT`: sucesso; binario retornou `di-explicit:ok`. +- `dotnet run --project .\test\Dapper.FluentMap.AotSmoke\Dapper.FluentMap.AotSmoke.csproj --configuration Release -p:DefineConstants=AOT_SMOKE_DI_GENERATED`: sucesso; binario retornou `di-generated:ok`. +- `dotnet publish .\test\Dapper.FluentMap.AotSmoke\Dapper.FluentMap.AotSmoke.csproj --configuration Release -p:PublishTrimmed=true -p:DefineConstants=AOT_SMOKE_DI_EXPLICIT --output .\.tmp\aot-smoke\di-explicit-trimmed`: sucesso; warning conhecido `IL2104` do Dapper. +- `.\.tmp\aot-smoke\di-explicit-trimmed\Dapper.FluentMap.AotSmoke.exe`: sucesso; binario retornou `di-explicit:ok`. +- `dotnet publish .\test\Dapper.FluentMap.AotSmoke\Dapper.FluentMap.AotSmoke.csproj --configuration Release -p:PublishTrimmed=true -p:DefineConstants=AOT_SMOKE_DI_GENERATED --output .\.tmp\aot-smoke\di-generated-trimmed`: sucesso; warnings conhecidos `IL2104` de `Dapper.FluentMap`/Dapper. +- `.\.tmp\aot-smoke\di-generated-trimmed\Dapper.FluentMap.AotSmoke.exe`: sucesso; binario retornou `di-generated:ok`. +- `dotnet publish .\test\Dapper.FluentMap.AotSmoke\Dapper.FluentMap.AotSmoke.csproj --configuration Release -p:PublishAot=true -p:DefineConstants=AOT_SMOKE_DI_EXPLICIT --output .\.tmp\aot-smoke\di-explicit-aot`: bloqueado pelo ambiente; erro `Platform linker not found`, exigindo prerequisites de Native AOT/Desktop Development for C++. +- `dotnet build .\Dapper.FluentMap.sln --configuration Release --no-restore`: sucesso, 0 warnings, 0 errors. +- `dotnet test .\Dapper.FluentMap.sln --configuration Release --no-build`: sucesso, 443 testes aprovados no total. +- `dotnet pack .\src\Dapper.FluentMap.DependencyInjection\Dapper.FluentMap.DependencyInjection.csproj --configuration Release --no-build --output .\artifacts\packages`: sucesso; criou `artifacts\packages\Dapper.FluentMap.DependencyInjection.2.0.0.nupkg`. +- Inspecionado `artifacts\packages\Dapper.FluentMap.DependencyInjection.2.0.0.nupkg`: contem `README.md`, `lib/netstandard2.0/Dapper.FluentMap.DependencyInjection.dll`, XML documentation e nuspec; dependencias `Dapper.FluentMap` 2.0.0 e `Microsoft.Extensions.DependencyInjection.Abstractions` 10.0.10; nao contem projetos de teste. + ## Ultimo prompt executado -Ultimo prompt executado: 11.4 +Ultimo prompt executado: 11.5 diff --git a/Dapper.FluentMap.sln b/Dapper.FluentMap.sln index 828780f..bd35504 100644 --- a/Dapper.FluentMap.sln +++ b/Dapper.FluentMap.sln @@ -35,6 +35,10 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "benchmarks", "benchmarks", EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Dapper.FluentMap.Benchmarks", "benchmarks\Dapper.FluentMap.Benchmarks\Dapper.FluentMap.Benchmarks.csproj", "{B09CFDAC-19CB-48F2-B7F7-03A47430C707}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Dapper.FluentMap.DependencyInjection", "src\Dapper.FluentMap.DependencyInjection\Dapper.FluentMap.DependencyInjection.csproj", "{23695A6A-DC6C-44F0-99DF-8570AC9118F2}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Dapper.FluentMap.DependencyInjection.Tests", "test\Dapper.FluentMap.DependencyInjection.Tests\Dapper.FluentMap.DependencyInjection.Tests.csproj", "{D90BE707-E2E4-4085-A09D-11BE81B379A5}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -85,6 +89,14 @@ Global {B09CFDAC-19CB-48F2-B7F7-03A47430C707}.Debug|Any CPU.Build.0 = Debug|Any CPU {B09CFDAC-19CB-48F2-B7F7-03A47430C707}.Release|Any CPU.ActiveCfg = Release|Any CPU {B09CFDAC-19CB-48F2-B7F7-03A47430C707}.Release|Any CPU.Build.0 = Release|Any CPU + {23695A6A-DC6C-44F0-99DF-8570AC9118F2}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {23695A6A-DC6C-44F0-99DF-8570AC9118F2}.Debug|Any CPU.Build.0 = Debug|Any CPU + {23695A6A-DC6C-44F0-99DF-8570AC9118F2}.Release|Any CPU.ActiveCfg = Release|Any CPU + {23695A6A-DC6C-44F0-99DF-8570AC9118F2}.Release|Any CPU.Build.0 = Release|Any CPU + {D90BE707-E2E4-4085-A09D-11BE81B379A5}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {D90BE707-E2E4-4085-A09D-11BE81B379A5}.Debug|Any CPU.Build.0 = Debug|Any CPU + {D90BE707-E2E4-4085-A09D-11BE81B379A5}.Release|Any CPU.ActiveCfg = Release|Any CPU + {D90BE707-E2E4-4085-A09D-11BE81B379A5}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -101,6 +113,8 @@ Global {BA72BEA0-BB6E-41EE-ABFE-215FF0A1E9BB} = {742442F2-CAE7-4DC8-BD73-8C54C0005A53} {87E09F49-F805-44EB-BA59-87C93C68497D} = {742442F2-CAE7-4DC8-BD73-8C54C0005A53} {B09CFDAC-19CB-48F2-B7F7-03A47430C707} = {66320409-64EC-F7C5-3DEF-65E7510DAAD1} + {23695A6A-DC6C-44F0-99DF-8570AC9118F2} = {580E3446-6579-4414-9875-970849E635E5} + {D90BE707-E2E4-4085-A09D-11BE81B379A5} = {742442F2-CAE7-4DC8-BD73-8C54C0005A53} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {10834736-59FD-47FF-9344-096247DC48CD} diff --git a/README.md b/README.md index 635a317..fe544c2 100644 --- a/README.md +++ b/README.md @@ -26,6 +26,7 @@ Install the package that matches the functionality you need: | Package | Purpose | |---|---| | `Dapper.FluentMap` | Core mapping API and Dapper integration. | +| `Dapper.FluentMap.DependencyInjection` | Optional Microsoft.Extensions.DependencyInjection 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. | @@ -351,6 +352,56 @@ process. `FluentMapper.Initialize(...)` remains the global compatibility layer, with `FluentMapper.Configuration` and `FluentMapper.Runtime` exposing the currently published default configuration and runtime. +## Dependency Injection + +Install `Dapper.FluentMap.DependencyInjection` when using ASP.NET Core, Worker +Services or the generic host: + +```bash +dotnet add package Dapper.FluentMap.DependencyInjection +``` + +Register FluentMap during service composition: + +```csharp +using Microsoft.Extensions.DependencyInjection; + +services.AddFluentMap(builder => +{ + builder.AddMap(); + builder.Configure(config => config.AddGeneratedMappings()); +}); +``` + +`AddFluentMap(...)` builds and validates the immutable configuration +immediately, then registers both `ImmutableFluentMapConfiguration` and +`FluentMapRuntime` as singletons. Use the resolved runtime with the +configuration-aware query APIs: + +```csharp +public sealed class CustomerReader +{ + private readonly FluentMapRuntime _runtime; + + public CustomerReader(FluentMapRuntime runtime) + { + _runtime = runtime; + } + + public Customer Read(IDbConnection connection) + { + return _runtime.QueryMappedSingle( + connection, + "SELECT 7 AS customer_id, 'Ada' AS name;"); + } +} +``` + +The DI package does not register database connections, repositories, Dommel +bridges or global Dapper type maps. Use explicit or generated registration for +trimmed and Native AOT applications; assembly scanning remains available but is +not the recommended DI path for those deployments. + ## Conventions and Naming Policies Conventions let you map repeated column patterns: @@ -787,6 +838,7 @@ Instale o pacote conforme a funcionalidade necessária: | Pacote | Finalidade | |---|---| | `Dapper.FluentMap` | API principal de mapeamento e integração com Dapper. | +| `Dapper.FluentMap.DependencyInjection` | Integração opcional com Microsoft.Extensions.DependencyInjection. | | `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. | @@ -1114,6 +1166,55 @@ mesmo processo. `FluentMapper.Initialize(...)` continua sendo a camada global de compatibilidade, com `FluentMapper.Configuration` e `FluentMapper.Runtime` expondo a configuracao e o runtime default publicados. +## Dependency Injection + +Instale `Dapper.FluentMap.DependencyInjection` ao usar ASP.NET Core, Worker +Services ou generic host: + +```bash +dotnet add package Dapper.FluentMap.DependencyInjection +``` + +Registre o FluentMap na composição de serviços: + +```csharp +using Microsoft.Extensions.DependencyInjection; + +services.AddFluentMap(builder => +{ + builder.AddMap(); + builder.Configure(config => config.AddGeneratedMappings()); +}); +``` + +`AddFluentMap(...)` constrói e valida a configuração imutável imediatamente, e +registra `ImmutableFluentMapConfiguration` e `FluentMapRuntime` como singletons. +Use o runtime resolvido com as APIs de query por configuração: + +```csharp +public sealed class CustomerReader +{ + private readonly FluentMapRuntime _runtime; + + public CustomerReader(FluentMapRuntime runtime) + { + _runtime = runtime; + } + + public Customer Read(IDbConnection connection) + { + return _runtime.QueryMappedSingle( + connection, + "SELECT 7 AS customer_id, 'Ada' AS name;"); + } +} +``` + +O pacote de DI não registra conexões, repositories, bridges Dommel ou type maps +globais do Dapper. Use registro explícito ou gerado para aplicações com +trimming e Native AOT; assembly scanning continua disponível, mas não é o +caminho recomendado em DI para esses deployments. + ## Convenções e Políticas de Nomenclatura Convenções permitem mapear padrões repetidos de colunas: diff --git a/src/Dapper.FluentMap.DependencyInjection/Dapper.FluentMap.DependencyInjection.csproj b/src/Dapper.FluentMap.DependencyInjection/Dapper.FluentMap.DependencyInjection.csproj new file mode 100644 index 0000000..7f1acb1 --- /dev/null +++ b/src/Dapper.FluentMap.DependencyInjection/Dapper.FluentMap.DependencyInjection.csproj @@ -0,0 +1,23 @@ + + + Dependency injection integration for Dapper.FluentMap. + Copyright © Henk Mollema 2014 + 2.0.0 + Henk Mollema + netstandard2.0 + 8.0 + true + Dapper.FluentMap.DependencyInjection + c#;dapper;mapping;fluentmap;dependency-injection + https://github.com/henkmollema/Dapper-FluentMap + MIT + README.md + + + + + + + + + diff --git a/src/Dapper.FluentMap.DependencyInjection/FluentMapServiceCollectionExtensions.cs b/src/Dapper.FluentMap.DependencyInjection/FluentMapServiceCollectionExtensions.cs new file mode 100644 index 0000000..f8a30f6 --- /dev/null +++ b/src/Dapper.FluentMap.DependencyInjection/FluentMapServiceCollectionExtensions.cs @@ -0,0 +1,46 @@ +using System; +using Dapper.FluentMap; +using Dapper.FluentMap.Configuration; + +namespace Microsoft.Extensions.DependencyInjection +{ + /// + /// Provides dependency injection registration methods for Dapper.FluentMap. + /// + public static class FluentMapServiceCollectionExtensions + { + /// + /// Builds and validates a FluentMap configuration, then registers the immutable configuration + /// and its runtime as singleton services. + /// + /// The service collection to add FluentMap services to. + /// The startup registration callback used to configure FluentMap maps, profiles, conventions and generated materializers. + /// The same service collection so calls can be chained. + public static IServiceCollection AddFluentMap( + this IServiceCollection services, + Action configure) + { + if (services == null) + { + throw new ArgumentNullException(nameof(services)); + } + + if (configure == null) + { + throw new ArgumentNullException(nameof(configure)); + } + + var builder = new FluentMapConfigurationBuilder(); + configure(builder); + + var configuration = builder.Build(); + var runtime = configuration.CreateRuntime(); + runtime.Validate(); + + services.AddSingleton(configuration); + services.AddSingleton(runtime); + + return services; + } + } +} diff --git a/src/Dapper.FluentMap.DependencyInjection/README.md b/src/Dapper.FluentMap.DependencyInjection/README.md new file mode 100644 index 0000000..f7679a4 --- /dev/null +++ b/src/Dapper.FluentMap.DependencyInjection/README.md @@ -0,0 +1,21 @@ +# Dapper.FluentMap.DependencyInjection + +Dependency injection integration for Dapper.FluentMap. + +```csharp +using Microsoft.Extensions.DependencyInjection; + +services.AddFluentMap(builder => +{ + builder.AddMap(); + builder.Configure(config => config.AddGeneratedMappings()); +}); +``` + +`AddFluentMap(...)` builds and validates the configuration during service +composition, then registers `ImmutableFluentMapConfiguration` and +`FluentMapRuntime` as singleton services. + +The package does not register database connections, repositories, Dommel +bridges or global Dapper type maps. Use explicit or generated map registration +for trimmed and Native AOT applications. diff --git a/test/Dapper.FluentMap.AotSmoke/Dapper.FluentMap.AotSmoke.csproj b/test/Dapper.FluentMap.AotSmoke/Dapper.FluentMap.AotSmoke.csproj index 9086546..167ded4 100644 --- a/test/Dapper.FluentMap.AotSmoke/Dapper.FluentMap.AotSmoke.csproj +++ b/test/Dapper.FluentMap.AotSmoke/Dapper.FluentMap.AotSmoke.csproj @@ -8,15 +8,20 @@ - + + ..\..\src\Dapper.FluentMap\bin\$(Configuration)\netstandard2.0\Dapper.FluentMap.dll + + ..\..\src\Dapper.FluentMap.DependencyInjection\bin\$(Configuration)\netstandard2.0\Dapper.FluentMap.DependencyInjection.dll + + - + diff --git a/test/Dapper.FluentMap.AotSmoke/Program.cs b/test/Dapper.FluentMap.AotSmoke/Program.cs index d7c1f29..139c6a1 100644 --- a/test/Dapper.FluentMap.AotSmoke/Program.cs +++ b/test/Dapper.FluentMap.AotSmoke/Program.cs @@ -1,4 +1,5 @@ using System; +using System.Diagnostics.CodeAnalysis; using System.Linq; using Dapper; using Dapper.FluentMap; @@ -6,6 +7,7 @@ using Dapper.FluentMap.Mapping; using Dapper.FluentMap.Naming; using Microsoft.Data.Sqlite; +using Microsoft.Extensions.DependencyInjection; #if AOT_SMOKE_GENERATED const string scenario = "generated"; @@ -22,6 +24,40 @@ AssertValueObjectExplain(); AssertProfileExplain(); AssertGeneratedQueryMappedMaterializer(); +#elif AOT_SMOKE_DI_GENERATED +const string scenario = "di-generated"; +using (var provider = new ServiceCollection() + .AddFluentMap(builder => + { + builder.Configure(configuration => configuration.AddGeneratedMappings()); + builder.UseNamingPolicy(NamingPolicy.SnakeCase).ForEntity(); + }) + .BuildServiceProvider()) +{ + var runtime = provider.GetRequiredService(); + AssertRuntimeMappedMember(runtime, "customer_id", nameof(Customer.Id)); + AssertRuntimeMappedMember(runtime, "created_at", nameof(NamingCustomer.CreatedAt)); + AssertRuntimeProfileExplain(runtime); + AssertRuntimeGeneratedRegistration(runtime); +} +#elif AOT_SMOKE_DI_EXPLICIT +const string scenario = "di-explicit"; +using (var provider = new ServiceCollection() + .AddFluentMap(builder => + { + builder.AddMap(); + builder.AddMap(); + builder.AddMap(); + builder.AddProfile(); + builder.UseNamingPolicy(NamingPolicy.SnakeCase).ForEntity(); + }) + .BuildServiceProvider()) +{ + var runtime = provider.GetRequiredService(); + AssertRuntimeMappedMember(runtime, "customer_id", nameof(Customer.Id)); + AssertRuntimeMappedMember(runtime, "created_at", nameof(NamingCustomer.CreatedAt)); + AssertRuntimeProfileExplain(runtime); +} #elif AOT_SMOKE_SCANNING const string scenario = "scanning"; FluentMapper.Initialize(configuration => configuration.AddMapsFromAssemblyContaining()); @@ -46,8 +82,28 @@ AssertProfileExplain(); #endif +#if AOT_SMOKE_DI_GENERATED || AOT_SMOKE_DI_EXPLICIT +static void AssertRuntimeMappedMember< + [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors | DynamicallyAccessedMemberTypes.PublicProperties)] + TEntity>( + FluentMapRuntime runtime, + string columnName, + string propertyName) +{ + var explanation = runtime.Explain(); + if (!explanation.Members.Any(member => + member.ColumnName == columnName && + member.MemberPath == propertyName)) + { + throw new InvalidOperationException( + $"Runtime explanation did not map column '{columnName}' to member '{propertyName}'."); + } +} +#endif + Console.WriteLine(scenario + ":ok"); +#if !AOT_SMOKE_DI_GENERATED && !AOT_SMOKE_DI_EXPLICIT static void AssertMappedMember(string columnName, string propertyName) { var member = SqlMapper.GetTypeMap(typeof(TEntity)).GetMember(columnName); @@ -57,8 +113,9 @@ static void AssertMappedMember(string columnName, string propertyName) $"Column '{columnName}' was not mapped to property '{propertyName}'."); } } +#endif -#if !AOT_SMOKE_SCANNING +#if !AOT_SMOKE_SCANNING && !AOT_SMOKE_DI_GENERATED && !AOT_SMOKE_DI_EXPLICIT static void AssertConstructorMapping() { var typeMap = SqlMapper.GetTypeMap(typeof(ImmutableCustomer)); @@ -112,6 +169,32 @@ static void AssertProfileExplain() throw new InvalidOperationException("Explain did not include the profile mapping."); } } + +#endif + +#if AOT_SMOKE_DI_GENERATED || AOT_SMOKE_DI_EXPLICIT +static void AssertRuntimeProfileExplain(FluentMapRuntime runtime) +{ + var explanation = runtime.Explain(); + if (explanation.ProfileType != typeof(LegacyProfile) || + !explanation.Members.Any(member => + member.MemberPath == nameof(Customer.Id) && + member.ColumnName == "legacy_id")) + { + throw new InvalidOperationException("Runtime Explain did not include the profile mapping."); + } +} +#endif + +#if AOT_SMOKE_DI_GENERATED +static void AssertRuntimeGeneratedRegistration(FluentMapRuntime runtime) +{ + if (!runtime.Configuration.GeneratedMaterializers.Any(materializer => + materializer.EntityType == typeof(Customer))) + { + throw new InvalidOperationException("DI runtime did not include generated materializer metadata."); + } +} #endif #if AOT_SMOKE_GENERATED diff --git a/test/Dapper.FluentMap.DependencyInjection.Tests/Dapper.FluentMap.DependencyInjection.Tests.csproj b/test/Dapper.FluentMap.DependencyInjection.Tests/Dapper.FluentMap.DependencyInjection.Tests.csproj new file mode 100644 index 0000000..d347577 --- /dev/null +++ b/test/Dapper.FluentMap.DependencyInjection.Tests/Dapper.FluentMap.DependencyInjection.Tests.csproj @@ -0,0 +1,18 @@ + + + net10.0 + false + + + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + diff --git a/test/Dapper.FluentMap.DependencyInjection.Tests/FluentMapServiceCollectionExtensionsTests.cs b/test/Dapper.FluentMap.DependencyInjection.Tests/FluentMapServiceCollectionExtensionsTests.cs new file mode 100644 index 0000000..5cf1320 --- /dev/null +++ b/test/Dapper.FluentMap.DependencyInjection.Tests/FluentMapServiceCollectionExtensionsTests.cs @@ -0,0 +1,260 @@ +using System; +using System.Linq; +using System.Threading.Tasks; +using Dapper.FluentMap; +using Dapper.FluentMap.Configuration; +using Dapper.FluentMap.Mapping; +using Microsoft.Data.Sqlite; +using Microsoft.Extensions.DependencyInjection; +using Xunit; + +namespace Dapper.FluentMap.DependencyInjection.Tests +{ + public sealed class FluentMapServiceCollectionExtensionsTests + { + [Fact] + public void AddFluentMapShouldRegisterConfigurationAndRuntimeAsSingletons() + { + var services = new ServiceCollection(); + + services.AddFluentMap(builder => builder.AddMap()); + + Assert.Contains(services, descriptor => + descriptor.ServiceType == typeof(ImmutableFluentMapConfiguration) && + descriptor.Lifetime == ServiceLifetime.Singleton); + Assert.Contains(services, descriptor => + descriptor.ServiceType == typeof(FluentMapRuntime) && + descriptor.Lifetime == ServiceLifetime.Singleton); + + using (var provider = services.BuildServiceProvider()) + { + var configuration = provider.GetRequiredService(); + var runtime = provider.GetRequiredService(); + + Assert.Same(configuration, provider.GetRequiredService()); + Assert.Same(runtime, provider.GetRequiredService()); + Assert.Same(configuration, runtime.Configuration); + Assert.True(configuration.EntityMaps.ContainsKey(typeof(DiCustomer))); + } + } + + [Fact] + [Trait("Category", "Integration")] + public void ResolvedRuntimeShouldMaterializeRows() + { + using (var provider = new ServiceCollection() + .AddFluentMap(builder => builder.AddMap()) + .BuildServiceProvider()) + using (var connection = OpenConnection()) + { + var runtime = provider.GetRequiredService(); + var customer = runtime.QueryMappedSingle( + connection, + "SELECT 7 AS customer_id, 'Ada' AS customer_name;"); + + Assert.Equal(7, customer.Id); + Assert.Equal("Ada", customer.Name); + } + } + + [Fact] + public void AddFluentMapShouldFailFastForInvalidConfiguration() + { + var services = new ServiceCollection(); + var map = new InvalidAfterRegistrationMap(); + + var exception = Assert.Throws(() => + services.AddFluentMap(builder => + { + builder.AddMap(map); + map.PropertyMaps.Add(null); + })); + + Assert.Contains("configuration validation found", exception.Message); + } + + [Fact] + [Trait("Category", "Integration")] + public void AddFluentMapShouldSupportExplicitInstanceRegistration() + { + using (var provider = new ServiceCollection() + .AddFluentMap(builder => builder.AddMap(new ExplicitInstanceCustomerMap())) + .BuildServiceProvider()) + using (var connection = OpenConnection()) + { + var customer = provider.GetRequiredService() + .QueryMappedSingle( + connection, + "SELECT 9 AS instance_id;"); + + Assert.Equal(9, customer.Id); + } + } + + [Fact] + [Trait("Category", "Integration")] + public void AddFluentMapShouldSupportProfiles() + { + using (var provider = new ServiceCollection() + .AddFluentMap(builder => + { + builder.AddMap(); + builder.AddProfile(); + }) + .BuildServiceProvider()) + using (var connection = OpenConnection()) + { + var current = provider.GetRequiredService() + .QueryMappedSingle( + connection, + "SELECT 1 AS customer_id, 'Current' AS customer_name;"); + var legacy = provider.GetRequiredService() + .QueryMappedSingle( + connection, + "SELECT 2 AS legacy_id, 'Legacy' AS legacy_name;"); + + Assert.Equal("Current", current.Name); + Assert.Equal(2, legacy.Id); + Assert.Equal("Legacy", legacy.Name); + } + } + + [Fact] + [Trait("Category", "Integration")] + public void IndependentServiceProvidersShouldKeepIndependentConfigurations() + { + using (var currentProvider = new ServiceCollection() + .AddFluentMap(builder => builder.AddMap(new CurrentCustomerMap())) + .BuildServiceProvider()) + using (var legacyProvider = new ServiceCollection() + .AddFluentMap(builder => builder.AddMap(new AlternateCustomerMap())) + .BuildServiceProvider()) + using (var connection = OpenConnection()) + { + var current = currentProvider.GetRequiredService() + .QueryMappedSingle( + connection, + "SELECT 3 AS customer_id, 'Current' AS customer_name;"); + var legacy = legacyProvider.GetRequiredService() + .QueryMappedSingle( + connection, + "SELECT 4 AS customer_id, 'Alternate' AS alternate_name;"); + + Assert.Equal("Current", current.Name); + Assert.Equal("Alternate", legacy.Name); + Assert.NotSame( + currentProvider.GetRequiredService(), + legacyProvider.GetRequiredService()); + } + } + + [Fact] + [Trait("Category", "Integration")] + public void SingletonRuntimeShouldSupportConcurrentQueries() + { + using (var provider = new ServiceCollection() + .AddFluentMap(builder => builder.AddMap()) + .BuildServiceProvider()) + { + var runtime = provider.GetRequiredService(); + + var results = Enumerable.Range(0, 32) + .AsParallel() + .Select(index => + { + using (var connection = OpenConnection()) + { + var customer = runtime.QueryMappedSingle( + connection, + $"SELECT {index} AS customer_id, 'customer-{index}' AS customer_name;"); + + return customer.Id == index && customer.Name == $"customer-{index}"; + } + }) + .ToList(); + + Assert.All(results, Assert.True); + } + } + + [Fact] + public void AddFluentMapShouldRejectNullArguments() + { + var services = new ServiceCollection(); + + Assert.Throws(() => ((IServiceCollection)null).AddFluentMap(_ => { })); + Assert.Throws(() => services.AddFluentMap(null)); + } + + private static SqliteConnection OpenConnection() + { + var connection = new SqliteConnection("Data Source=:memory:"); + connection.Open(); + return connection; + } + + private sealed class LegacyProfile : IMappingProfile + { + } + + private sealed class DiCustomer + { + public int Id { get; set; } + + public string Name { get; set; } + } + + private sealed class CurrentCustomerMap : EntityMap + { + public CurrentCustomerMap() + { + Map(customer => customer.Id).ToColumn("customer_id"); + Map(customer => customer.Name).ToColumn("customer_name"); + } + } + + private sealed class AlternateCustomerMap : EntityMap + { + public AlternateCustomerMap() + { + Map(customer => customer.Id).ToColumn("customer_id"); + Map(customer => customer.Name).ToColumn("alternate_name"); + } + } + + private sealed class LegacyCustomerMap : EntityMap, IProfileMap + { + public LegacyCustomerMap() + { + Map(customer => customer.Id).ToColumn("legacy_id"); + Map(customer => customer.Name).ToColumn("legacy_name"); + } + } + + private sealed class ExplicitInstanceCustomer + { + public int Id { get; set; } + } + + private sealed class ExplicitInstanceCustomerMap : EntityMap + { + public ExplicitInstanceCustomerMap() + { + Map(customer => customer.Id).ToColumn("instance_id"); + } + } + + private sealed class InvalidAfterRegistrationEntity + { + public int Id { get; set; } + } + + private sealed class InvalidAfterRegistrationMap : EntityMap + { + public InvalidAfterRegistrationMap() + { + Map(entity => entity.Id).ToColumn("invalid_id"); + } + } + } +} diff --git a/test/Dapper.FluentMap.GeneratedRegistration.Tests/Dapper.FluentMap.GeneratedRegistration.Tests.csproj b/test/Dapper.FluentMap.GeneratedRegistration.Tests/Dapper.FluentMap.GeneratedRegistration.Tests.csproj index 03ed900..437cb6b 100644 --- a/test/Dapper.FluentMap.GeneratedRegistration.Tests/Dapper.FluentMap.GeneratedRegistration.Tests.csproj +++ b/test/Dapper.FluentMap.GeneratedRegistration.Tests/Dapper.FluentMap.GeneratedRegistration.Tests.csproj @@ -4,10 +4,12 @@ false + + diff --git a/test/Dapper.FluentMap.GeneratedRegistration.Tests/GeneratedRegistrationIntegrationTests.cs b/test/Dapper.FluentMap.GeneratedRegistration.Tests/GeneratedRegistrationIntegrationTests.cs index 0e89e7a..a7ca603 100644 --- a/test/Dapper.FluentMap.GeneratedRegistration.Tests/GeneratedRegistrationIntegrationTests.cs +++ b/test/Dapper.FluentMap.GeneratedRegistration.Tests/GeneratedRegistrationIntegrationTests.cs @@ -1,9 +1,11 @@ using System; using System.Linq; using Dapper; +using Dapper.FluentMap.Configuration; using Dapper.FluentMap.Mapping; using Dapper.FluentMap.Naming; using Microsoft.Data.Sqlite; +using Microsoft.Extensions.DependencyInjection; using Xunit; namespace Dapper.FluentMap.GeneratedRegistration.Tests @@ -111,6 +113,29 @@ public void GeneratedRegistrationShouldWorkWithDapperAndExistingMappingFeatures( } } + [Fact] + [Trait("Category", "Integration")] + public void GeneratedRegistrationShouldWorkThroughDependencyInjection() + { + using (var provider = new ServiceCollection() + .AddFluentMap(builder => builder.Configure(configuration => configuration.AddGeneratedMappings())) + .BuildServiceProvider()) + using (var connection = OpenConnection()) + { + var configuration = provider.GetRequiredService(); + var runtime = provider.GetRequiredService(); + + var customer = runtime.QueryMappedSingle( + connection, + "SELECT 41 AS customer_id, 'DI' AS Name;"); + + Assert.Same(configuration, runtime.Configuration); + Assert.Contains(configuration.GeneratedMaterializers, materializer => materializer.EntityType == typeof(GeneratedCustomer)); + Assert.Equal(41, customer.Id); + Assert.Equal("DI", customer.Name); + } + } + [Fact] [Trait("Category", "Integration")] public void GeneratedQueryMappedShouldMatchRuntimeFallbackForEquivalentComplexShapes() From a84b990f7777b66961622b980588f9e05c9bfc94 Mon Sep 17 00:00:00 2001 From: Rodrigo de Oliveira Date: Wed, 29 Jul 2026 10:03:50 -0300 Subject: [PATCH 34/49] test(configuration): harden isolation and concurrency --- .../01-historical-configuration-issues.md | 25 + .sdd/etapa-11/05-performance-impact.md | 56 +++ .sdd/etapa-11/08-isolation-matrix.md | 85 ++++ .sdd/etapa-11/09-migration-guide.md | 163 +++++++ .sdd/etapa-11/STATUS.md | 31 +- ...uentMapServiceCollectionExtensionsTests.cs | 46 ++ .../ManualMappingTests.cs | 50 ++ .../CompatibilityBridgeTests.cs | 20 +- .../ConfigurationIsolationHardeningTests.cs | 451 ++++++++++++++++++ 9 files changed, 921 insertions(+), 6 deletions(-) create mode 100644 .sdd/etapa-11/08-isolation-matrix.md create mode 100644 .sdd/etapa-11/09-migration-guide.md create mode 100644 test/Dapper.FluentMap.Tests/ConfigurationIsolationHardeningTests.cs diff --git a/.sdd/etapa-11/01-historical-configuration-issues.md b/.sdd/etapa-11/01-historical-configuration-issues.md index dbe7b42..ed59182 100644 --- a/.sdd/etapa-11/01-historical-configuration-issues.md +++ b/.sdd/etapa-11/01-historical-configuration-issues.md @@ -58,6 +58,31 @@ consumidores e `FluentMapConfigurationBuilder -> Build() -> configuration.CreateRuntime()`. O reset interno permanece para testes e compatibilidade. +Atualizacao do prompt 11.6: estado final da #101 e **Partially resolved**. + +Por que nao "Resolved structurally" integral: + +- a causa arquitetural foi resolvida para APIs controladas pelo FluentMap: + `runtime.QueryMapped()`, profiles, converters, generated materializers, + diagnostics e DI podem usar configuracoes independentes no mesmo processo; +- testes novos provam uso concorrente de multiplos runtimes para o mesmo tipo + sem `FluentMapper.Reset()`; +- a bridge estatica legado continua process-wide por compatibilidade; +- `Dapper.Query()` continua limitado pelo `SqlMapper.SetTypeMap` global por + tipo e nao pode selecionar configuracao por chamada; +- Dommel continua limitado por resolvers/builders globais de `DommelMapper` e + por metadata especifica mantida nas colecoes legadas. + +Estado final: + +```text +Issue #101: Partially resolved +``` + +Resolvido estruturalmente para novos entry points isolados. Nao resolvido para +o uso legado direto de `Dapper.Query()` ou Dommel com multiplas configuracoes +simultaneas. + ## Issue #79 Fonte: https://github.com/henkmollema/Dapper-FluentMap/issues/79 diff --git a/.sdd/etapa-11/05-performance-impact.md b/.sdd/etapa-11/05-performance-impact.md index e595dd9..e70c89a 100644 --- a/.sdd/etapa-11/05-performance-impact.md +++ b/.sdd/etapa-11/05-performance-impact.md @@ -90,3 +90,59 @@ Benchmarks completos continuam recomendados antes de release, especialmente: O custo de memoria por runtime e intencional e deve ser documentado como troca por isolamento correto entre configuracoes. + +## Repeticao no prompt 11.6 + +Comando executado: + +```powershell +dotnet run --project .\benchmarks\Dapper.FluentMap.Benchmarks\Dapper.FluentMap.Benchmarks.csproj --configuration Release -- --filter "*MaterializationSteadyStateBenchmarks*QueryMappedSimple*" --job Dry +``` + +Ambiente reportado: + +- Windows 11; +- .NET SDK 10.0.302; +- runtime .NET 10.0.10; +- BenchmarkDotNet 0.15.8. + +Observacao: o benchmark atual ja contem os cenarios "after isolated runtime", +"legacy default runtime" e "isolated runtime". Um baseline executavel "before +isolated runtime" nao existe no estado atual do workspace sem voltar o codigo +historico; portanto a comparacao antes/depois usa os resultados registrados +no prompt 11.3 como referencia historica. + +ShortRun, 1000 linhas em SQLite in-memory: + +| Comparacao | Metodo | Mean | Allocated | +| --- | --- | ---: | ---: | +| legacy default runtime | `QueryMappedSimple` | 1.759 ms | 261.16 KB | +| isolated runtime | `RuntimeQueryMappedSimple` | 1.731 ms | 261.16 KB | +| legacy default runtime | `QueryMappedSimpleUnbuffered` | 1.786 ms | 245.20 KB | +| isolated runtime | `RuntimeQueryMappedSimpleUnbuffered` | 1.786 ms | 245.20 KB | +| legacy default runtime | `QueryMappedSimpleUnbufferedAsync` | 2.801 ms | 245.61 KB | +| isolated runtime | `RuntimeQueryMappedSimpleUnbufferedAsync` | 2.222 ms | 245.61 KB | +| legacy default runtime | `QueryMappedSimpleRuntimeFallback` | 1.859 ms | 361.58 KB | +| isolated runtime | `RuntimeQueryMappedSimpleRuntimeFallback` | 1.839 ms | 361.58 KB | + +Dry, uma iteracao cold/smoke: + +| Comparacao | Metodo | Mean | Allocated | +| --- | --- | ---: | ---: | +| legacy default runtime | `QueryMappedSimple` | 3.109 ms | 362.83 KB | +| isolated runtime | `RuntimeQueryMappedSimple` | 2.722 ms | 362.83 KB | +| legacy default runtime | `QueryMappedSimpleUnbuffered` | 2.677 ms | 346.80 KB | +| isolated runtime | `RuntimeQueryMappedSimpleUnbuffered` | 2.600 ms | 346.80 KB | +| legacy default runtime | `QueryMappedSimpleUnbufferedAsync` | 2.777 ms | 347.30 KB | +| isolated runtime | `RuntimeQueryMappedSimpleUnbufferedAsync` | 3.356 ms | 347.30 KB | +| legacy default runtime | `QueryMappedSimpleRuntimeFallback` | 2.567 ms | 361.63 KB | +| isolated runtime | `RuntimeQueryMappedSimpleRuntimeFallback` | 2.695 ms | 361.63 KB | + +Leitura do prompt 11.6: + +- alocacao segue equivalente nos pares comparaveis; +- runtime isolado nao introduziu alocacao extra observavel no steady-state; +- diferencas de tempo em `ShortRun`/`Dry` continuam pequenas e com margem alta, + entao devem ser tratadas como smoke/guardrail; +- benchmark completo segue recomendado antes de release para cold start, + muitos runtimes ativos e cenarios com converters/generated materializers. diff --git a/.sdd/etapa-11/08-isolation-matrix.md b/.sdd/etapa-11/08-isolation-matrix.md new file mode 100644 index 0000000..caedaa6 --- /dev/null +++ b/.sdd/etapa-11/08-isolation-matrix.md @@ -0,0 +1,85 @@ +# Configuration Isolation Matrix + +## Escopo + +Esta matriz registra o estado comprovado no prompt 11.6 para a arquitetura: + +```text +FluentMapConfigurationBuilder + -> ImmutableFluentMapConfiguration + -> FluentMapRuntime +``` + +O foco e provar isolamento para APIs novas controladas pelo FluentMap. APIs +legadas que dependem de estado process-wide foram testadas separadamente e +documentadas como limites, nao como isolamento pleno. + +## Matriz + +| Scenario | Expected isolation | Test | +| --- | --- | --- | +| Same type, different mapping | Suportado por runtimes distintos. O mesmo tipo de entidade pode ter `Name` vindo de colunas diferentes quando a chamada usa `runtime.QueryMapped()`. Caches de materializacao ficam em cada runtime. | `ConfigurationIsolationHardeningTests.IsolatedRuntimesShouldMaterializeSameEntityWithDifferentMappingsConcurrently` | +| Same profile, different config | Suportado por runtimes distintos. O mesmo tipo de profile pode mapear a mesma entidade de formas diferentes sem colisao quando usado por `runtime.QueryMapped()`. | `ConfigurationIsolationHardeningTests.ProfilesConvertersAndDiagnosticsShouldStayIsolatedAcrossConcurrentRuntimes`; cobertura previa em `IsolatedRuntimeTests.RuntimeShouldKeepSameProfileTypeIsolatedAcrossConfigurations` | +| Same converter, different config | Suportado quando converter metadata pertence ao snapshot do map de cada runtime. O mesmo tipo de converter pode ser usado por runtimes com colunas diferentes sem colisao. Instancias/delegates continuam assumidos stateless/thread-safe. | `ConfigurationIsolationHardeningTests.SameConverterTypeShouldRemainScopedToDifferentRuntimeMappings`; `ConfigurationIsolationHardeningTests.ProfilesConvertersAndDiagnosticsShouldStayIsolatedAcrossConcurrentRuntimes`; cobertura previa em `IsolatedRuntimeTests.RuntimeShouldScopeConvertersToConfiguration` | +| Generated materializer | Suportado. Descritores gerados pertencem ao snapshot e sao indexados no registry do runtime; dois runtimes podem registrar o mesmo shape com delegates diferentes. | `ConfigurationIsolationHardeningTests.GeneratedMaterializersShouldRemainConfigurationScopedUnderConcurrentMaterialization`; cobertura previa em `IsolatedRuntimeTests.RuntimeShouldScopeGeneratedMaterializersToConfiguration` | +| Dommel metadata | Nao isolado por runtime nesta etapa. Resolvers Dommel consultam `FluentMapper.EntityMaps`/`TypeConventions` e sao instalados globalmente em `DommelMapper`. Configuracoes isoladas do core nao dirigem Dommel. | `ManualMappingTests.DommelResolversShouldUseOnlyLegacyProcessWideConfiguration` | +| Diagnostics | Suportado por runtime. `runtime.Validate()` e `runtime.Explain()` usam o registry do runtime e nao leem `FluentMapper.Runtime`. | `ConfigurationIsolationHardeningTests.ProfilesConvertersAndDiagnosticsShouldStayIsolatedAcrossConcurrentRuntimes`; cobertura previa em `IsolatedRuntimeTests.RuntimeDiagnosticsShouldUseItsConfigurationWithoutGlobalState` | +| Parallel tests | Suportado para novos APIs por testes que criam runtimes locais sem `FluentMapper.Reset()`. A suite principal ainda desabilita paralelismo no assembly por causa de testes legados globais. | `ConfigurationIsolationHardeningTests.IsolatedRuntimesShouldMaterializeSameEntityWithDifferentMappingsConcurrently`; `ConfigurationIsolationHardeningTests.SameRuntimeShouldMaterializeConcurrentReadersThroughOneScopedCache`; `FluentMapServiceCollectionExtensionsTests.IndependentServiceProvidersShouldResolveIndependentRuntimesConcurrently` | + +## Limites globais comprovados + +### Dapper + +`Dapper.Query()` usa o type map registrado em `SqlMapper.SetTypeMap`, que e +process-wide por tipo. Portanto, duas configuracoes FluentMap distintas nao +podem controlar simultaneamente `connection.Query()` para o mesmo `T`. + +Teste: + +- `ConfigurationIsolationHardeningTests.DapperQueryShouldUseOnlyThePublishedGlobalTypeMapForSameEntity` + +Conclusao: + +- `runtime.QueryMapped()`: isolado por runtime; +- `connection.Query()`: limitado ao type map global publicado pela bridge + estatica. + +### Dommel + +`ForDommel()` instala resolvers/builders no `DommelMapper` global. Os resolvers +atuais leem as colecoes legadas de `FluentMapper`, inclusive metadata Dommel +especifica. Configuracoes criadas por `FluentMapConfigurationBuilder` nao sao +observadas por Dommel. + +Teste: + +- `ManualMappingTests.DommelResolversShouldUseOnlyLegacyProcessWideConfiguration` + +Conclusao: + +- Dommel segue bridge process-wide; +- multiplas configuracoes Dommel simultaneas nao sao contrato suportado nesta + etapa. + +## Concorrencia + +Cobertura adicionada usa `Barrier` para sincronizar inicio das operacoes e +exercitar pontos de corrida importantes sem depender apenas de loops longos: + +- multiplos runtimes usando a mesma entidade; +- mesmo runtime em varias threads; +- generated materializers concorrentes; +- profiles/converters/diagnostics concorrentes; +- service providers DI independentes; +- inicializacao estatica concorrente serializada pela bridge. + +## Estado da matriz + +Estado final do prompt 11.6: + +```text +Core isolated runtime: resolved structurally for QueryMapped/runtime APIs +Legacy static bridge: compatibility only, process-wide +Dapper.Query integration: structurally limited by Dapper global type maps +Dommel integration: structurally limited by Dommel global resolvers/builders +``` diff --git a/.sdd/etapa-11/09-migration-guide.md b/.sdd/etapa-11/09-migration-guide.md new file mode 100644 index 0000000..6f765e6 --- /dev/null +++ b/.sdd/etapa-11/09-migration-guide.md @@ -0,0 +1,163 @@ +# Configuration Isolation Migration Guide + +## Legacy + +Codigo existente continua suportado: + +```csharp +FluentMapper.Initialize(configuration => +{ + configuration.AddMap(new CustomerMap()); +}); + +using var connection = OpenConnection(); +var customer = connection.Query( + "SELECT 1 AS customer_id, 'Ada' AS customer_name;") + .Single(); +``` + +Use este caminho quando a aplicacao possui uma unica configuracao global e +precisa que `Dapper.Query()` use o type map instalado em `SqlMapper`. + +## Isolated configuration + +Para codigo novo que precisa de isolamento, crie um builder, congele a +configuracao e use um runtime explicito: + +```csharp +var configuration = new FluentMapConfigurationBuilder() + .AddMap(new CustomerMap()) + .Build(); + +var runtime = configuration.CreateRuntime(); + +using var connection = OpenConnection(); +var customer = runtime.QueryMappedSingle( + connection, + "SELECT 1 AS customer_id, 'Ada' AS customer_name;"); +``` + +Esse caminho nao instala type maps globais do Dapper e nao exige +`FluentMapper.Reset()` em testes. + +## Dependency Injection + +No pacote `Dapper.FluentMap.DependencyInjection`: + +```csharp +var services = new ServiceCollection(); + +services.AddFluentMap(builder => +{ + builder.AddMap(new CustomerMap()); +}); + +using var provider = services.BuildServiceProvider(); +var runtime = provider.GetRequiredService(); +``` + +`ImmutableFluentMapConfiguration` e `FluentMapRuntime` sao registrados como +singletons. O FluentMap nao registra `IDbConnection`, repositories ou unidade +de trabalho. + +## Test isolation + +Para novos testes, prefira criar runtime local: + +```csharp +var runtime = new FluentMapConfigurationBuilder() + .AddMap(new CustomerMap()) + .Build() + .CreateRuntime(); + +using var connection = new SqliteConnection("Data Source=:memory:"); +connection.Open(); + +var customer = runtime.QueryMappedSingle( + connection, + "SELECT 42 AS customer_id, 'Grace' AS customer_name;"); +``` + +Esse teste pode rodar junto de outro teste que cria outro runtime para o mesmo +tipo com colunas diferentes, porque caches e metadata derivados pertencem ao +runtime. + +## Multiple configurations + +Suportado para APIs controladas pelo runtime: + +```csharp +var current = new FluentMapConfigurationBuilder() + .AddMap(new CurrentCustomerMap()) + .Build() + .CreateRuntime(); + +var legacy = new FluentMapConfigurationBuilder() + .AddMap(new LegacyCustomerMap()) + .Build() + .CreateRuntime(); + +var currentCustomer = current.QueryMappedSingle( + connection, + "SELECT 1 AS customer_id, 'Ada' AS customer_name;"); + +var legacyCustomer = legacy.QueryMappedSingle( + connection, + "SELECT 2 AS customer_id, 'Grace' AS legacy_name;"); +``` + +Os dois runtimes podem coexistir no mesmo processo e ate serem usados +concorrentemente quando cada chamada passa o runtime correto. + +## Generated registration + +O source generator continua compativel com o builder por `Configure(...)`: + +```csharp +var runtime = new FluentMapConfigurationBuilder() + .Configure(configuration => configuration.AddGeneratedMappings()) + .Build() + .CreateRuntime(); +``` + +Os generated materializers registrados assim ficam associados ao snapshot e ao +runtime criados por esse builder. + +## Known limitations + +### Dapper.Query() + +`connection.Query()` usa `SqlMapper.SetTypeMap`, que e global por tipo no +processo. Ele nao consegue escolher uma configuracao FluentMap por chamada. + +Quando precisar de multiplas configuracoes para o mesmo tipo: + +```csharp +var rows = runtime.QueryMapped( + connection, + "SELECT 1 AS customer_id, 'Ada' AS customer_name;"); +``` + +Nao use `connection.Query()` esperando que ele selecione o runtime +isolado. + +### Dommel + +`configuration.ForDommel()` instala resolvers e SQL builders globais no +`DommelMapper`. Os resolvers atuais leem `FluentMapper.EntityMaps` e +`FluentMapper.TypeConventions`. + +Consequencia: + +- uma configuracao criada apenas por `FluentMapConfigurationBuilder` nao dirige + Dommel; +- multiplas configuracoes Dommel simultaneas para o mesmo tipo nao sao + suportadas nesta etapa; +- nao ha bridge por runtime para Dommel no prompt 11.6. + +### Legacy mutable dictionaries + +`FluentMapper.EntityMaps` e `FluentMapper.TypeConventions` continuam mutaveis +por compatibilidade. Mutacao direta pode bypassar validacao, invalidacao de +cache e instalacao de type map Dapper. Codigo novo deve usar builder ou +`FluentMapper.Initialize(...)`. diff --git a/.sdd/etapa-11/STATUS.md b/.sdd/etapa-11/STATUS.md index 5ab06e2..74a3b3c 100644 --- a/.sdd/etapa-11/STATUS.md +++ b/.sdd/etapa-11/STATUS.md @@ -58,10 +58,17 @@ e preparando configuracoes imutaveis com runtime isolado. - Criados testes de registration, service resolution, singleton identity, invalid config, explicit registration, profiles, multiple service providers, independent configurations e concurrency. - Adicionado teste de generated registration via DI no projeto do source generator. - `README.md` atualizado com instalacao e uso de `Dapper.FluentMap.DependencyInjection`. +- Criado `08-isolation-matrix.md`. +- Criado `09-migration-guide.md`. +- Adicionados testes de hardening para runtimes isolados usando a mesma entidade com mappings diferentes, mesmo runtime em multiplas threads, generated materializers concorrentes, profiles/converters/diagnostics concorrentes, invalid configuration isolada, DI concorrente e inicializacao estatica controlada por `Barrier`. +- Adicionado teste que prova a limitacao estrutural de `Dapper.Query()`: o caminho puro do Dapper usa somente o type map global publicado, enquanto `runtime.QueryMapped()` usa a configuracao isolada. +- Adicionado teste que prova que Dommel resolve metadata pela configuracao legada process-wide e nao por runtimes isolados do core. +- Atualizada a classificacao final da issue #101 como `Partially resolved`. +- Repetido benchmark smoke de `MaterializationSteadyStateBenchmarks*QueryMappedSimple*` e registrado resultado no relatorio de performance. ## Em andamento -- Nenhum item em andamento para o prompt 11.4. +- Nenhum item em andamento para o prompt 11.6. ## Proximos passos @@ -158,6 +165,8 @@ e preparando configuracoes imutaveis com runtime isolado. - `.sdd/etapa-11/04-isolated-runtime.md` - `.sdd/etapa-11/05-performance-impact.md` - `.sdd/etapa-11/06-compatibility-bridge.md` +- `.sdd/etapa-11/08-isolation-matrix.md` +- `.sdd/etapa-11/09-migration-guide.md` - `.sdd/etapa-11/DECISIONS.md` - `.sdd/etapa-11/STATUS.md` - `README.md` @@ -173,6 +182,7 @@ e preparando configuracoes imutaveis com runtime isolado. - `src/Dapper.FluentMap/QueryMappedExtensions.cs` - `test/Dapper.FluentMap.Tests/ImmutableConfigurationModelTests.cs` - `test/Dapper.FluentMap.Tests/IsolatedRuntimeTests.cs` +- `test/Dapper.FluentMap.Tests/ConfigurationIsolationHardeningTests.cs` - `test/Dapper.FluentMap.Tests/CompatibilityBridgeTests.cs` - `.sdd/etapa-11/07-dependency-injection-spec.md` - `src/Dapper.FluentMap.DependencyInjection/Dapper.FluentMap.DependencyInjection.csproj` @@ -246,6 +256,23 @@ e preparando configuracoes imutaveis com runtime isolado. - `dotnet pack .\src\Dapper.FluentMap.DependencyInjection\Dapper.FluentMap.DependencyInjection.csproj --configuration Release --no-build --output .\artifacts\packages`: sucesso; criou `artifacts\packages\Dapper.FluentMap.DependencyInjection.2.0.0.nupkg`. - Inspecionado `artifacts\packages\Dapper.FluentMap.DependencyInjection.2.0.0.nupkg`: contem `README.md`, `lib/netstandard2.0/Dapper.FluentMap.DependencyInjection.dll`, XML documentation e nuspec; dependencias `Dapper.FluentMap` 2.0.0 e `Microsoft.Extensions.DependencyInjection.Abstractions` 10.0.10; nao contem projetos de teste. +## Validacao do Prompt 11.6 + +- Detectado runner de testes como VSTest: SDK `10.0.302`, sem `global.json`, sem `Directory.Build.props`/`Directory.Packages.props` e projetos de teste com `Microsoft.NET.Test.Sdk` + xUnit runner. +- Tentativa inicial de builds localizados em paralelo bloqueou no arquivo intermediario do core por `VBCSCompiler` (`CS2012`); repeticao sequencial passou. A falha foi de concorrencia entre comandos de build, nao de produto/teste. +- `dotnet build .\test\Dapper.FluentMap.Tests\Dapper.FluentMap.Tests.csproj --configuration Release`: sucesso, 0 warnings, 0 errors. +- `dotnet build .\test\Dapper.FluentMap.DependencyInjection.Tests\Dapper.FluentMap.DependencyInjection.Tests.csproj --configuration Release`: sucesso, 0 warnings, 0 errors. +- `dotnet build .\test\Dapper.FluentMap.Dommel.Tests\Dapper.FluentMap.Dommel.Tests.csproj --configuration Release`: sucesso, 0 warnings, 0 errors. +- `dotnet test .\test\Dapper.FluentMap.Tests\Dapper.FluentMap.Tests.csproj --configuration Release --no-build --filter "FullyQualifiedName~ConfigurationIsolationHardeningTests|FullyQualifiedName~CompatibilityBridgeTests"`: sucesso, 12 testes aprovados antes do reforco adicional de converter. +- `dotnet test .\test\Dapper.FluentMap.Tests\Dapper.FluentMap.Tests.csproj --configuration Release --no-build --filter "FullyQualifiedName~ConfigurationIsolationHardeningTests"`: sucesso, 8 testes aprovados apos adicionar a prova do mesmo converter em configuracoes diferentes. +- `dotnet test .\test\Dapper.FluentMap.DependencyInjection.Tests\Dapper.FluentMap.DependencyInjection.Tests.csproj --configuration Release --no-build --filter "FullyQualifiedName~FluentMapServiceCollectionExtensionsTests"`: sucesso, 9 testes aprovados. +- `dotnet test .\test\Dapper.FluentMap.Dommel.Tests\Dapper.FluentMap.Dommel.Tests.csproj --configuration Release --no-build --filter "FullyQualifiedName~DommelResolversShouldUseOnlyLegacyProcessWideConfiguration"`: sucesso, 1 teste aprovado. +- `dotnet run --project .\benchmarks\Dapper.FluentMap.Benchmarks\Dapper.FluentMap.Benchmarks.csproj --configuration Release -- --filter "*MaterializationSteadyStateBenchmarks*QueryMappedSimple*" --job Dry`: sucesso; smoke executou 20 benchmarks `Dry`/`ShortRun`, registrado em `05-performance-impact.md`. +- `dotnet restore .\Dapper.FluentMap.sln`: sucesso. +- `dotnet build .\Dapper.FluentMap.sln --configuration Release --no-restore`: sucesso, 0 warnings, 0 errors. +- `dotnet test .\Dapper.FluentMap.sln --configuration Release --no-build`: sucesso, 453 testes aprovados no total. +- `dotnet pack`: nao executado; o prompt 11.6 alterou testes e documentacao SDD, sem mudanca de empacotamento, metadata de pacote ou assemblies de producao. + ## Ultimo prompt executado -Ultimo prompt executado: 11.5 +Ultimo prompt executado: 11.6 diff --git a/test/Dapper.FluentMap.DependencyInjection.Tests/FluentMapServiceCollectionExtensionsTests.cs b/test/Dapper.FluentMap.DependencyInjection.Tests/FluentMapServiceCollectionExtensionsTests.cs index 5cf1320..36ab10f 100644 --- a/test/Dapper.FluentMap.DependencyInjection.Tests/FluentMapServiceCollectionExtensionsTests.cs +++ b/test/Dapper.FluentMap.DependencyInjection.Tests/FluentMapServiceCollectionExtensionsTests.cs @@ -1,5 +1,6 @@ using System; using System.Linq; +using System.Threading; using System.Threading.Tasks; using Dapper.FluentMap; using Dapper.FluentMap.Configuration; @@ -177,6 +178,51 @@ public void SingletonRuntimeShouldSupportConcurrentQueries() } } + [Fact] + [Trait("Category", "Integration")] + public async Task IndependentServiceProvidersShouldResolveIndependentRuntimesConcurrently() + { + using (var currentProvider = new ServiceCollection() + .AddFluentMap(builder => builder.AddMap(new CurrentCustomerMap())) + .BuildServiceProvider()) + using (var legacyProvider = new ServiceCollection() + .AddFluentMap(builder => builder.AddMap(new AlternateCustomerMap())) + .BuildServiceProvider()) + { + var start = new Barrier(2); + var currentTask = Task.Run(() => + { + start.SignalAndWait(); + using (var connection = OpenConnection()) + { + return currentProvider.GetRequiredService() + .QueryMappedSingle( + connection, + "SELECT 11 AS customer_id, 'Current' AS customer_name;"); + } + }); + var legacyTask = Task.Run(() => + { + start.SignalAndWait(); + using (var connection = OpenConnection()) + { + return legacyProvider.GetRequiredService() + .QueryMappedSingle( + connection, + "SELECT 12 AS customer_id, 'Alternate' AS alternate_name;"); + } + }); + + var customers = await Task.WhenAll(currentTask, legacyTask); + + Assert.Equal("Current", customers[0].Name); + Assert.Equal("Alternate", customers[1].Name); + Assert.NotSame( + currentProvider.GetRequiredService(), + legacyProvider.GetRequiredService()); + } + } + [Fact] public void AddFluentMapShouldRejectNullArguments() { diff --git a/test/Dapper.FluentMap.Dommel.Tests/ManualMappingTests.cs b/test/Dapper.FluentMap.Dommel.Tests/ManualMappingTests.cs index 049212e..1a66d8d 100644 --- a/test/Dapper.FluentMap.Dommel.Tests/ManualMappingTests.cs +++ b/test/Dapper.FluentMap.Dommel.Tests/ManualMappingTests.cs @@ -1,4 +1,5 @@ using Dapper.FluentMap.Dommel.Mapping; +using Dapper.FluentMap.Configuration; using Dapper.FluentMap.Mapping; using System; using System.ComponentModel.DataAnnotations.Schema; @@ -216,6 +217,32 @@ public void CoreReadOnlyMetadataShouldBeConsumableByDommelPropertyResolver() Assert.True(name.IsGenerated); } + [Fact] + public void DommelResolversShouldUseOnlyLegacyProcessWideConfiguration() + { + PreTest(); + + var isolatedRuntime = new FluentMapConfigurationBuilder() + .AddMap(new IsolatedDommelTableMap()) + .Build() + .CreateRuntime(); + var resolver = new Dommel.Resolvers.DommelTableNameResolver(); + + var beforeGlobalRegistration = resolver.ResolveTableName(typeof(DommelIsolationEntity)); + + FluentMapper.Initialize(c => + { + c.AddMap(new LegacyDommelTableMap()); + c.ForDommel(); + }); + + var afterGlobalRegistration = resolver.ResolveTableName(typeof(DommelIsolationEntity)); + + Assert.NotNull(isolatedRuntime); + Assert.NotEqual("isolated_dommel_entities", beforeGlobalRegistration); + Assert.Equal("legacy_dommel_entities", afterGlobalRegistration); + } + private static void PreTest() { FluentMapper.EntityMaps.Clear(); @@ -280,5 +307,28 @@ public CoreReadOnlyMap() Map(p => p.Name).ReadOnly(); } } + + private sealed class DommelIsolationEntity + { + public int Id { get; set; } + } + + private sealed class IsolatedDommelTableMap : DommelEntityMap + { + public IsolatedDommelTableMap() + { + ToTable("isolated_dommel_entities"); + Map(entity => entity.Id).ToColumn("id").IsKey(); + } + } + + private sealed class LegacyDommelTableMap : DommelEntityMap + { + public LegacyDommelTableMap() + { + ToTable("legacy_dommel_entities"); + Map(entity => entity.Id).ToColumn("id").IsKey(); + } + } } } diff --git a/test/Dapper.FluentMap.Tests/CompatibilityBridgeTests.cs b/test/Dapper.FluentMap.Tests/CompatibilityBridgeTests.cs index c2078ab..1a091a8 100644 --- a/test/Dapper.FluentMap.Tests/CompatibilityBridgeTests.cs +++ b/test/Dapper.FluentMap.Tests/CompatibilityBridgeTests.cs @@ -1,5 +1,6 @@ using System; using System.Linq; +using System.Threading; using System.Threading.Tasks; using Dapper.FluentMap.Configuration; using Dapper.FluentMap.Mapping; @@ -66,15 +67,26 @@ public void RepeatedInitializeShouldPublishAdditiveDefaultConfiguration() } [Fact] - public void ConcurrentInitializeShouldBeSerializedForDefaultConfiguration() + public async Task ConcurrentInitializeShouldBeSerializedForDefaultConfiguration() { ResetMapper(typeof(ConcurrentFirstBridgeEntity), typeof(ConcurrentSecondBridgeEntity)); try { - Parallel.Invoke( - () => FluentMapper.Initialize(configuration => configuration.AddMap(new ConcurrentFirstBridgeMap())), - () => FluentMapper.Initialize(configuration => configuration.AddMap(new ConcurrentSecondBridgeMap()))); + var start = new Barrier(2); + var cancellationToken = TestContext.Current.CancellationToken; + var first = Task.Run(() => + { + start.SignalAndWait(); + FluentMapper.Initialize(configuration => configuration.AddMap(new ConcurrentFirstBridgeMap())); + }, cancellationToken); + var second = Task.Run(() => + { + start.SignalAndWait(); + FluentMapper.Initialize(configuration => configuration.AddMap(new ConcurrentSecondBridgeMap())); + }, cancellationToken); + + await Task.WhenAll(first, second); FluentMapper.Validate(); Assert.True(FluentMapper.Configuration.EntityMaps.ContainsKey(typeof(ConcurrentFirstBridgeEntity))); diff --git a/test/Dapper.FluentMap.Tests/ConfigurationIsolationHardeningTests.cs b/test/Dapper.FluentMap.Tests/ConfigurationIsolationHardeningTests.cs new file mode 100644 index 0000000..471be90 --- /dev/null +++ b/test/Dapper.FluentMap.Tests/ConfigurationIsolationHardeningTests.cs @@ -0,0 +1,451 @@ +using System; +using System.Collections.Generic; +using System.Data; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Dapper; +using Dapper.FluentMap.Configuration; +using Dapper.FluentMap.Mapping; +using Dapper.FluentMap.Materialization; +using Microsoft.Data.Sqlite; +using Xunit; + +namespace Dapper.FluentMap.Tests +{ + public sealed class ConfigurationIsolationHardeningTests + { + [Fact] + [Trait("Category", "Integration")] + public async Task IsolatedRuntimesShouldMaterializeSameEntityWithDifferentMappingsConcurrently() + { + var current = CreateRuntime(builder => builder.AddMap(new CurrentCustomerMap())); + var legacy = CreateRuntime(builder => builder.AddMap(new LegacyCustomerMap())); + var start = new Barrier(2); + + var currentTask = Task.Run(() => + { + start.SignalAndWait(); + using (var connection = OpenConnection()) + { + return current.QueryMappedSingle( + connection, + "SELECT 1 AS customer_id, 'Ada' AS customer_name;"); + } + }); + + var legacyTask = Task.Run(() => + { + start.SignalAndWait(); + using (var connection = OpenConnection()) + { + return legacy.QueryMappedSingle( + connection, + "SELECT 2 AS customer_id, 'Grace' AS legacy_name;"); + } + }); + + var customers = await Task.WhenAll(currentTask, legacyTask); + + Assert.Equal(1, customers[0].Id); + Assert.Equal("Ada", customers[0].Name); + Assert.Equal(2, customers[1].Id); + Assert.Equal("Grace", customers[1].Name); + Assert.Equal(1, current.MaterializationPlanCacheEntryCount); + Assert.Equal(1, legacy.MaterializationPlanCacheEntryCount); + } + + [Fact] + [Trait("Category", "Integration")] + public async Task SameRuntimeShouldMaterializeConcurrentReadersThroughOneScopedCache() + { + var runtime = CreateRuntime(builder => builder.AddMap(new CurrentCustomerMap())); + var start = new Barrier(8); + + var tasks = Enumerable.Range(0, 8) + .Select(index => Task.Run(() => + { + start.SignalAndWait(); + using (var connection = OpenConnection()) + { + return runtime.QueryMappedSingle( + connection, + $"SELECT {index} AS customer_id, 'customer-{index}' AS customer_name;"); + } + })) + .ToArray(); + + var customers = await Task.WhenAll(tasks); + + Assert.Equal( + Enumerable.Range(0, 8), + customers.Select(customer => customer.Id).OrderBy(id => id)); + Assert.All(customers, customer => Assert.Equal("customer-" + customer.Id, customer.Name)); + Assert.Equal(1, runtime.MaterializationPlanCacheEntryCount); + } + + [Fact] + [Trait("Category", "Integration")] + public async Task GeneratedMaterializersShouldRemainConfigurationScopedUnderConcurrentMaterialization() + { + var generatedA = CreateRuntime(builder => + { + builder.AddMap(new CurrentCustomerMap()); + builder.AddGeneratedMaterializer(CustomerGeneratedColumns(), record => ReadGeneratedCustomer(record, "A")); + }); + var generatedB = CreateRuntime(builder => + { + builder.AddMap(new CurrentCustomerMap()); + builder.AddGeneratedMaterializer(CustomerGeneratedColumns(), record => ReadGeneratedCustomer(record, "B")); + }); + var start = new Barrier(2); + + var firstTask = Task.Run(() => QueryGeneratedCustomer(generatedA, start)); + var secondTask = Task.Run(() => QueryGeneratedCustomer(generatedB, start)); + + var customers = await Task.WhenAll(firstTask, secondTask); + + Assert.Equal("A:Ada", customers[0].Name); + Assert.Equal("B:Ada", customers[1].Name); + Assert.Equal(0, generatedA.MaterializationPlanCacheEntryCount); + Assert.Equal(0, generatedB.MaterializationPlanCacheEntryCount); + } + + [Fact] + [Trait("Category", "Integration")] + public async Task SameConverterTypeShouldRemainScopedToDifferentRuntimeMappings() + { + var current = CreateRuntime(builder => builder.AddMap(new UpperConverterCustomerMap())); + var legacy = CreateRuntime(builder => builder.AddMap(new LegacyUpperConverterCustomerMap())); + var start = new Barrier(2); + + var currentTask = Task.Run(() => + { + start.SignalAndWait(); + using (var connection = OpenConnection()) + { + return current.QueryMappedSingle( + connection, + "SELECT 1 AS customer_id, 'ada' AS customer_name;"); + } + }); + var legacyTask = Task.Run(() => + { + start.SignalAndWait(); + using (var connection = OpenConnection()) + { + return legacy.QueryMappedSingle( + connection, + "SELECT 2 AS customer_id, 'grace' AS legacy_name;"); + } + }); + + var customers = await Task.WhenAll(currentTask, legacyTask); + + Assert.Equal("ADA", customers[0].Name); + Assert.Equal("GRACE", customers[1].Name); + } + + [Fact] + [Trait("Category", "Integration")] + public async Task ProfilesConvertersAndDiagnosticsShouldStayIsolatedAcrossConcurrentRuntimes() + { + var upper = CreateRuntime(builder => + { + builder.AddProfile(); + builder.AddMap(new UpperConverterCustomerMap()); + }); + var bracket = CreateRuntime(builder => + { + builder.AddProfile(); + builder.AddMap(new BracketConverterCustomerMap()); + }); + var start = new Barrier(4); + + var upperProfileTask = Task.Run(() => QueryProfileCustomer(upper, start, "profile_id", "ada")); + var bracketProfileTask = Task.Run(() => QueryProfileCustomer(bracket, start, "profile_id", "grace")); + var upperDiagnosticTask = Task.Run(() => ExplainNameColumn(upper, start)); + var bracketDiagnosticTask = Task.Run(() => ExplainNameColumn(bracket, start)); + + var upperProfile = await upperProfileTask; + var bracketProfile = await bracketProfileTask; + var diagnosticColumns = await Task.WhenAll(upperDiagnosticTask, bracketDiagnosticTask); + + Assert.Equal("ADA", upperProfile.Name); + Assert.Equal("[grace]", bracketProfile.Name); + Assert.Equal(new[] { "customer_name", "alternate_name" }, diagnosticColumns); + } + + [Fact] + [Trait("Category", "Integration")] + public void DapperQueryShouldUseOnlyThePublishedGlobalTypeMapForSameEntity() + { + ResetMapper(typeof(IsolationCustomer)); + + try + { + FluentMapper.Initialize(configuration => configuration.AddMap(new CurrentCustomerMap())); + var isolatedLegacy = CreateRuntime(builder => builder.AddMap(new LegacyCustomerMap())); + + using (var connection = OpenConnection()) + { + var dapper = connection.QuerySingle( + "SELECT 9 AS customer_id, 'Legacy' AS legacy_name;"); + var fluentMap = isolatedLegacy.QueryMappedSingle( + connection, + "SELECT 9 AS customer_id, 'Legacy' AS legacy_name;"); + + Assert.Equal(9, dapper.Id); + Assert.Null(dapper.Name); + Assert.Equal("Legacy", fluentMap.Name); + } + } + finally + { + ResetMapper(typeof(IsolationCustomer)); + } + } + + [Fact] + [Trait("Category", "Integration")] + public void LegacyDefaultRuntimeShouldNotContaminatePreviouslyCreatedIsolatedRuntime() + { + ResetMapper(typeof(IsolationCustomer)); + + try + { + var isolatedLegacy = CreateRuntime(builder => builder.AddMap(new LegacyCustomerMap())); + FluentMapper.Initialize(configuration => configuration.AddMap(new CurrentCustomerMap())); + + using (var connection = OpenConnection()) + { + var legacy = isolatedLegacy.QueryMappedSingle( + connection, + "SELECT 4 AS customer_id, 'Legacy' AS legacy_name;"); + var current = connection.QueryMappedSingle( + "SELECT 5 AS customer_id, 'Current' AS customer_name;"); + + Assert.Equal("Legacy", legacy.Name); + Assert.Equal("Current", current.Name); + } + } + finally + { + ResetMapper(typeof(IsolationCustomer)); + } + } + + [Fact] + public void InvalidConfigurationShouldNotPoisonIndependentValidConfiguration() + { + var invalidMap = new InvalidAfterRegistrationMap(); + var exception = Assert.Throws(() => + { + var invalidBuilder = new FluentMapConfigurationBuilder(); + invalidBuilder.AddMap(invalidMap); + invalidMap.PropertyMaps.Add(null); + invalidBuilder.Build(); + }); + + var validRuntime = CreateRuntime(builder => builder.AddMap(new CurrentCustomerMap())); + + Assert.Contains("configuration validation found", exception.Message); + validRuntime.Validate(); + Assert.Equal("customer_name", ExplainNameColumn(validRuntime)); + } + + private static FluentMapRuntime CreateRuntime(Action configure) + { + var builder = new FluentMapConfigurationBuilder(); + configure(builder); + return builder.Build().CreateRuntime(); + } + + private static IsolationCustomer QueryGeneratedCustomer(FluentMapRuntime runtime, Barrier start) + { + start.SignalAndWait(); + using (var connection = OpenConnection()) + { + return runtime.QueryMappedSingle( + connection, + "SELECT 1 AS customer_id, 'Ada' AS customer_name;"); + } + } + + private static IsolationCustomer QueryProfileCustomer( + FluentMapRuntime runtime, + Barrier start, + string idColumn, + string name) + where TProfile : IMappingProfile + { + start.SignalAndWait(); + using (var connection = OpenConnection()) + { + return runtime.QueryMappedSingle( + connection, + $"SELECT 7 AS {idColumn}, '{name}' AS profile_name;"); + } + } + + private static string ExplainNameColumn(FluentMapRuntime runtime, Barrier start = null) + { + if (start != null) + { + start.SignalAndWait(); + } + + return runtime.Explain() + .Members + .Single(member => member.MemberPath == nameof(IsolationCustomer.Name)) + .ColumnName; + } + + private static GeneratedMaterializerColumn[] CustomerGeneratedColumns() + { + return new[] + { + GeneratedMaterializerColumn.Map("customer_id", nameof(IsolationCustomer.Id)), + GeneratedMaterializerColumn.Map("customer_name", nameof(IsolationCustomer.Name)) + }; + } + + private static IsolationCustomer ReadGeneratedCustomer(IDataRecord record, string prefix) + { + return new IsolationCustomer + { + Id = Convert.ToInt32(record.GetValue(0)), + Name = prefix + ":" + Convert.ToString(record.GetValue(1)) + }; + } + + 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 RuntimeProfile : IMappingProfile + { + } + + private sealed class IsolationCustomer + { + public int Id { get; set; } + + public string Name { get; set; } + } + + private sealed class CurrentCustomerMap : EntityMap + { + public CurrentCustomerMap() + { + Map(customer => customer.Id).ToColumn("customer_id"); + Map(customer => customer.Name).ToColumn("customer_name"); + } + } + + private sealed class LegacyCustomerMap : EntityMap + { + public LegacyCustomerMap() + { + Map(customer => customer.Id).ToColumn("customer_id"); + Map(customer => customer.Name).ToColumn("legacy_name"); + } + } + + private sealed class UpperProfileCustomerMap : + EntityMap, + IProfileMap + { + public UpperProfileCustomerMap() + { + Map(customer => customer.Id).ToColumn("profile_id"); + Map(customer => customer.Name) + .ToColumn("profile_name") + .ConvertFromDatabaseUsing(); + } + } + + private sealed class BracketProfileCustomerMap : + EntityMap, + IProfileMap + { + public BracketProfileCustomerMap() + { + Map(customer => customer.Id).ToColumn("profile_id"); + Map(customer => customer.Name) + .ToColumn("profile_name") + .ConvertFromDatabaseUsing(); + } + } + + private sealed class UpperConverterCustomerMap : EntityMap + { + public UpperConverterCustomerMap() + { + Map(customer => customer.Id).ToColumn("customer_id"); + Map(customer => customer.Name) + .ToColumn("customer_name") + .ConvertFromDatabaseUsing(); + } + } + + private sealed class LegacyUpperConverterCustomerMap : EntityMap + { + public LegacyUpperConverterCustomerMap() + { + Map(customer => customer.Id).ToColumn("customer_id"); + Map(customer => customer.Name) + .ToColumn("legacy_name") + .ConvertFromDatabaseUsing(); + } + } + + private sealed class BracketConverterCustomerMap : EntityMap + { + public BracketConverterCustomerMap() + { + Map(customer => customer.Id).ToColumn("customer_id"); + Map(customer => customer.Name) + .ToColumn("alternate_name") + .ConvertFromDatabaseUsing(); + } + } + + private sealed class UpperNameConverter : IReadPropertyConverter + { + public string ConvertFromDatabase(string value) + { + return value.ToUpperInvariant(); + } + } + + private sealed class BracketNameConverter : IReadPropertyConverter + { + public string ConvertFromDatabase(string value) + { + return "[" + value + "]"; + } + } + + private sealed class InvalidAfterRegistrationEntity + { + public int Id { get; set; } + } + + private sealed class InvalidAfterRegistrationMap : EntityMap + { + public InvalidAfterRegistrationMap() + { + Map(entity => entity.Id).ToColumn("invalid_id"); + } + } + } +} From 10492c071c9515e95ffdcaef1372f14bd01b4f33 Mon Sep 17 00:00:00 2001 From: Rodrigo de Oliveira Date: Wed, 29 Jul 2026 10:22:12 -0300 Subject: [PATCH 35/49] docs(configuration): finalize isolation and DI support --- .sdd/etapa-11/05-performance-impact.md | 38 +++ .sdd/etapa-11/FINAL-REPORT.md | 396 +++++++++++++++++++++++++ .sdd/etapa-11/STATUS.md | 52 +++- README.md | 154 +++++++++- 4 files changed, 629 insertions(+), 11 deletions(-) create mode 100644 .sdd/etapa-11/FINAL-REPORT.md diff --git a/.sdd/etapa-11/05-performance-impact.md b/.sdd/etapa-11/05-performance-impact.md index e70c89a..66620c6 100644 --- a/.sdd/etapa-11/05-performance-impact.md +++ b/.sdd/etapa-11/05-performance-impact.md @@ -146,3 +146,41 @@ Leitura do prompt 11.6: entao devem ser tratadas como smoke/guardrail; - benchmark completo segue recomendado antes de release para cold start, muitos runtimes ativos e cenarios com converters/generated materializers. + +## Repeticao no prompt 11.7 + +Comando executado: + +```powershell +dotnet run --project .\benchmarks\Dapper.FluentMap.Benchmarks\Dapper.FluentMap.Benchmarks.csproj --configuration Release -- --filter "*MaterializationSteadyStateBenchmarks*QueryMappedSimple*" --job Dry +``` + +Ambiente reportado: + +- Windows 11; +- .NET SDK 10.0.302; +- runtime .NET 10.0.10; +- BenchmarkDotNet 0.15.8. + +ShortRun, 1000 linhas em SQLite in-memory: + +| Comparacao | Metodo | Mean | Allocated | +| --- | --- | ---: | ---: | +| legacy default runtime | `QueryMappedSimple` | 1.976 ms | 261.16 KB | +| isolated runtime | `RuntimeQueryMappedSimple` | 2.070 ms | 261.16 KB | +| legacy default runtime | `QueryMappedSimpleUnbuffered` | 2.295 ms | 245.20 KB | +| isolated runtime | `RuntimeQueryMappedSimpleUnbuffered` | 2.667 ms | 245.20 KB | +| legacy default runtime | `QueryMappedSimpleUnbufferedAsync` | 2.002 ms | 245.61 KB | +| isolated runtime | `RuntimeQueryMappedSimpleUnbufferedAsync` | 2.155 ms | 245.61 KB | +| legacy default runtime | `QueryMappedSimpleRuntimeFallback` | 1.965 ms | 361.58 KB | +| isolated runtime | `RuntimeQueryMappedSimpleRuntimeFallback` | 1.922 ms | 361.58 KB | + +Leitura do prompt 11.7: + +- alocacao permaneceu equivalente nos pares comparaveis entre bridge estatica + e runtime isolado; +- nao ha evidencia de lookup significativo por linha introduzido pelo runtime, + porque a resolucao segue ocorrendo na criacao do materializer por reader; +- os tempos continuam ruidosos por `ShortRun`/`Dry`, com aviso de iteracoes + abaixo de 100 ms, portanto nao devem ser usados como claim formal de + throughput. diff --git a/.sdd/etapa-11/FINAL-REPORT.md b/.sdd/etapa-11/FINAL-REPORT.md new file mode 100644 index 0000000..f866f74 --- /dev/null +++ b/.sdd/etapa-11/FINAL-REPORT.md @@ -0,0 +1,396 @@ +# Etapa 11 — Final Report + +## Objetivo + +Encerrar a Etapa 11 - Configuration Isolation & Dependency Injection com +auditoria de especificacao, API publica, imutabilidade, isolamento, +compatibilidade estatica, DI, performance, trimming/AOT e documentacao publica, +sem iniciar funcionalidades da Etapa 12. + +## Implementado + +| Requirement | Implementation | Tests | Compatibility | Status | +| ----------- | -------------- | ----- | ------------- | ------ | +| Separar configuracao mutavel de runtime | `FluentMapConfigurationBuilder -> Build() -> ImmutableFluentMapConfiguration -> FluentMapRuntime` | `ImmutableConfigurationModelTests`, `IsolatedRuntimeTests` | Aditivo; `FluentMapConfiguration` historico preservado | Completed | +| Builder com DSL existente | `FluentMapConfigurationBuilder` delega para uma `FluentMapConfiguration` sobre registry isolado; `Configure(...)` reusa extensoes existentes e generated registration | Builder/configuration tests e generated DI test | Sem remover DSL antiga | Completed | +| Snapshot imutavel | `ImmutableFluentMapConfiguration` expoe `IReadOnly*` e descritores de maps, profiles, conventions, property metadata e generated materializers | `BuildShouldNotExposeMutableEffectiveCollections`, `BuildShouldCaptureSnapshotIndependentFromLaterMapMutation` | Interfaces publicas mutaveis antigas continuam fora do modelo novo | Completed | +| Runtime isolado | `FluentMapRuntime` possui registry proprio reconstruido do snapshot | `RuntimeShouldMaterializeSameEntityWithIndependentConfigurations` e hardening concorrente | Entry points novos por runtime sao opt-in | Completed | +| Caches por configuracao/runtime | Property map cache, generated lookup e materialization plan cache vivem no `MappingRegistry` de cada runtime | cache isolation tests e benchmarks runtime/static | Caches globais legados permanecem apenas na bridge estatica | Completed | +| Query APIs por runtime | Metodos de instancia em `FluentMapRuntime` cobrem `QueryMapped`, profiles, single, unbuffered sync/async e `QueryMultipleMapped` | `IsolatedRuntimeTests`, `ConfigurationIsolationHardeningTests` | Helpers estaticos continuam usando runtime default | Completed | +| Profiles isolados | Profiles ficam no snapshot e sao resolvidos por runtime | testes com mesmo profile type em configuracoes diferentes | Sem alterar semantica query-scoped | Completed | +| Converters isolados | Conversion metadata e instancias/delegates sao capturados por snapshot e reconstruidos por runtime | converter isolation tests | Mantido contrato stateless/thread-safe | Completed | +| Persistence metadata imutavel | `PropertyPersistenceMetadata` e copiada para `PropertyMappingConfiguration` | snapshot tests e Dommel persistence suite | Core continua metadata-only para writes | Completed | +| Generated materializers por runtime | Descritores ficam no snapshot e sao registrados no registry do runtime | generated isolation/concurrency tests; generated registration DI test | Fallback runtime preservado | Completed | +| Bridge estatica compativel | `FluentMapper.Initialize(...)` publica `FluentMapper.Configuration` e `FluentMapper.Runtime` e reinstala type maps Dapper | `CompatibilityBridgeTests` | `Initialize` aditivo, campos publicos e `Dapper.Query()` preservados | Completed | +| Dapper global type map | Documentado e testado como limite process-wide | `DapperQueryShouldUseOnlyThePublishedGlobalTypeMapForSameEntity` | Compatibilidade mantida; nao isolado por chamada | Partial | +| Dommel | Mantido como bridge process-wide sobre colecoes legadas | `DommelResolversShouldUseOnlyLegacyProcessWideConfiguration` | Sem promessa de isolamento Dommel nesta etapa | Partial | +| DI opcional | Pacote `Dapper.FluentMap.DependencyInjection` com `services.AddFluentMap(...)` | `FluentMapServiceCollectionExtensionsTests` | Core sem dependencia obrigatoria de DI | Completed | +| DI lifetimes | `ImmutableFluentMapConfiguration` e `FluentMapRuntime` registrados como singletons | singleton, providers independentes e concorrencia | Sem registrar conexao, repository ou Dommel | Completed | +| Trimming/AOT | Registro explicito/gerado e DI smoke; scanning continua anotado; `QueryMapped*` continua warning-sensitive | AOT smoke run/publish trimmed | Native AOT total nao declarado | Partial | +| Performance | Runtime resolvido por reader; hot path usa delegate/plano cacheado | `MaterializationSteadyStateBenchmarks*QueryMappedSimple*` smoke | Sem regressao de alocacao observavel | Completed | +| Test isolation | Novos testes usam runtime local sem `FluentMapper.Reset()` | isolation hardening e DI providers independentes | Testes legados globais continuam serializados | Completed | + +Divergencias e itens parciais: + +- `Dapper.Query()` nao e isolado por configuracao porque o Dapper usa + `SqlMapper.SetTypeMap` global por tipo. A solucao suportada para multiplas + configuracoes e `runtime.QueryMapped()`. +- Dommel nao e isolado por runtime porque `DommelMapper` usa resolvers/builders + globais e os resolvers atuais dependem de metadata especifica de + `DommelEntityMap`/`DommelPropertyMap` nas colecoes legadas. +- `QueryMapped*` nao foi declarado Native AOT-safe porque ainda pode cair no + materializer runtime baseado em reflection/dynamic code. + +## Configuration Builder + +`FluentMapConfigurationBuilder` e o ponto mutavel novo. Ele aceita maps, +profiles, conventions, naming policies, generated materializers e +`Configure(Action)`. O builder usa um +`MappingRegistry(installDapperTypeMaps: false)`, portanto nao instala type maps +globais do Dapper durante a construcao de configuracoes isoladas. + +`Build()` valida a configuracao, cria o snapshot imutavel e sela o builder. +Chamadas mutadoras posteriores lancam `InvalidOperationException`; chamadas +posteriores a `Build()` retornam a mesma configuracao. + +## Immutable Configuration + +`ImmutableFluentMapConfiguration` captura: + +- maps default por entidade; +- profiles por entidade/profile; +- conventions e naming policies aplicadas por entidade; +- property maps com member path, coluna, case sensitivity e ignore; +- persistence metadata; +- conversion metadata; +- generated materializer descriptors. + +Depois do build, mutacoes tardias nos maps/conventions originais nao alteram o +snapshot. As colecoes expostas sao read-only. O snapshot nao expoe as instancias +mutaveis de `IEntityMap`, `Convention` ou `PropertyMap` como configuracao +efetiva. + +## Isolated Runtime + +`ImmutableFluentMapConfiguration.CreateRuntime()` cria um `FluentMapRuntime`. +O runtime reconstrui um registry interno a partir dos descritores imutaveis e +usa esse registry para materializacao, generated lookup, profile lookup, +converters e diagnostics. + +Os entry points de instancia cobrem os caminhos controlados pelo FluentMap: +`QueryMapped*`, `QueryMappedSingle*`, `QueryMappedUnbuffered*`, +`QueryMappedUnbufferedAsync*` e `QueryMultipleMapped`. + +## Configuration-scoped Caches + +Cada runtime possui seus proprios caches derivados: + +- property map lookup; +- profile property map lookup; +- convention lookup; +- runtime materialization plan; +- generated materializer lookup/index. + +As chaves continuam usando tipo, profile, coluna/shape e estrategia de lookup. +Como o cache vive dentro do runtime, a identidade da configuracao e implicita. +O benchmark smoke do prompt 11.7 manteve alocacao equivalente entre helpers +estaticos e runtime isolado nos pares comparaveis. + +## Static Compatibility Layer + +`FluentMapper.Initialize(...)` continua aditivo e serializado por lock. Depois +de cada inicializacao, a bridge publica um novo runtime default e reinstala +type maps Dapper para entidades com maps default ou conventions. + +Preservado: + +- `FluentMapper.Initialize(...)`; +- `FluentMapper.Validate()`; +- `FluentMapper.Explain()` e profile; +- `FluentMapper.GetEntityMaps()`; +- `FluentMapper.GetTypeConventions()`; +- campos publicos `EntityMaps` e `TypeConventions`; +- comportamento aditivo de chamadas repetidas. + +Nao houve nova marcacao `[Obsolete]`. As colecoes publicas mutaveis foram +documentadas como compatibilidade legada. + +## Dependency Injection + +O pacote novo `Dapper.FluentMap.DependencyInjection` adiciona: + +```csharp +services.AddFluentMap(builder => +{ + builder.AddMap(); +}); +``` + +O callback recebe o builder, nao `IServiceProvider`. A configuracao e +construida e validada imediatamente; depois `ImmutableFluentMapConfiguration` e +`FluentMapRuntime` sao registrados como singletons. + +O pacote depende apenas de `Dapper.FluentMap` e +`Microsoft.Extensions.DependencyInjection.Abstractions`. O core nao passou a +ter dependencia obrigatoria de DI, Hosting, ASP.NET Core, Options, Logging ou +Dommel. + +## Multiple Configurations + +Suportado para materializacao controlada pelo FluentMap: + +```text +Configuration A -> Runtime A -> runtime.QueryMapped() +Configuration B -> Runtime B -> runtime.QueryMapped() +``` + +Testes provam o mesmo entity type com mappings diferentes, mesmo profile type +com metadata diferente, converter metadata por runtime e generated +materializers concorrentes sem colisao. + +Nao suportado como isolamento completo para: + +- `Dapper.Query()` tradicional; +- Dommel; +- mutacao direta das colecoes legadas sem nova publicacao pela bridge estatica. + +## Test Isolation + +Novos testes podem criar `FluentMapConfigurationBuilder`, chamar `Build()` e +usar `CreateRuntime()` sem `FluentMapper.Reset()`. Isso reduz dependencia de +estado global e permite testar duas configuracoes para o mesmo tipo no mesmo +processo. + +A suite ainda possui testes legados que exercitam `FluentMapper.Initialize`, +Dapper type maps globais e Dommel. Esses testes continuam usando reset interno +e paralelismo desabilitado onde necessario. + +## Concurrency + +O modelo efetivo e: + +- builder mutavel, usado em startup/composition root e nao thread-safe; +- configuracao imutavel read-only, segura para leitura concorrente; +- runtime thread-safe com caches `ConcurrentDictionary`; +- materializers e planos resolvidos por reader e reutilizados por linha; +- converters reutilizados concorrentemente conforme contrato stateless ou + thread-safe. + +Testes com `Barrier` cobrem runtimes distintos, mesmo runtime em varias +threads, generated materializers concorrentes, profiles/converters/diagnostics +concorrentes e providers DI independentes. + +## Dapper Global Integration Limitations + +`SqlMapper.SetTypeMap` e global por entity type. A bridge estatica instala type +maps para preservar `connection.Query()`, mas esse caminho nao seleciona uma +configuracao FluentMap por chamada. + +Conclusao: + +- `runtime.QueryMapped()`: isolado por runtime; +- `connection.Query()`: usa o type map global publicado pelo + `FluentMapper.Initialize(...)` mais recente para aquele tipo. + +Essa limitacao e upstream/estrutural e esta documentada no README e na matriz +de isolamento. + +## Dommel Integration Limitations + +Dommel permanece process-wide. `ForDommel()` instala resolvers/builders globais +em `DommelMapper`. Os resolvers atuais leem `FluentMapper.EntityMaps` e +`FluentMapper.TypeConventions`, preservando metadata especifica de Dommel. + +Configuracoes criadas apenas por `FluentMapConfigurationBuilder` nao dirigem +Dommel. Multiplas configuracoes Dommel simultaneas para o mesmo tipo nao sao +contrato suportado na Etapa 11. + +## Performance + +O hot path permanece: + +```text +reader shape + -> generated lookup ou runtime materialization plan cache + -> delegate/plano reutilizado por row +``` + +Benchmark smoke do prompt 11.7: + +| Comparacao | Metodo | Mean | Allocated | +| --- | --- | ---: | ---: | +| legacy default runtime | `QueryMappedSimple` | 1.976 ms | 261.16 KB | +| isolated runtime | `RuntimeQueryMappedSimple` | 2.070 ms | 261.16 KB | +| legacy default runtime | `QueryMappedSimpleUnbuffered` | 2.295 ms | 245.20 KB | +| isolated runtime | `RuntimeQueryMappedSimpleUnbuffered` | 2.667 ms | 245.20 KB | +| legacy default runtime | `QueryMappedSimpleRuntimeFallback` | 1.965 ms | 361.58 KB | +| isolated runtime | `RuntimeQueryMappedSimpleRuntimeFallback` | 1.922 ms | 361.58 KB | + +O BenchmarkDotNet avisou que as iteracoes ficaram abaixo de 100 ms. Use estes +numeros como smoke de alocacao/guardrail, nao como benchmark formal de release. + +## Native AOT / Trimming + +Validado: + +- `dotnet run` smoke explicito: `explicit:ok`; +- `dotnet run` smoke generated: `generated:ok`; +- `dotnet run` smoke DI explicito: `di-explicit:ok`; +- `dotnet run` smoke DI generated: `di-generated:ok`; +- `PublishTrimmed=true` DI explicito: sucesso; warning conhecido `IL2104` do + Dapper; binario executou `di-explicit:ok`; +- `PublishTrimmed=true` DI generated: sucesso; warnings conhecidos `IL2104` + de `Dapper.FluentMap`/Dapper; binario executou `di-generated:ok`. + +Native AOT smoke: + +- `PublishAot=true` DI explicito foi bloqueado pelo ambiente com + `Platform linker not found`; +- o erro pede os prerequisitos Native AOT/Desktop Development for C++; +- nenhuma compatibilidade Native AOT total foi declarada. + +Assembly scanning continua anotado como trimming-sensitive. `QueryMapped*` +continua anotado com `RequiresUnreferencedCode` e `RequiresDynamicCode` porque +pode cair no fallback runtime. + +## Backward Compatibility + +Compatibilidade de fonte: + +- APIs estaticas existentes permanecem; +- campos publicos legados permanecem; +- `FluentMapConfiguration` continua tipo publico mutavel; +- `Initialize` continua aditivo; +- nenhuma nova `[Obsolete]` foi aplicada. + +Compatibilidade comportamental: + +- `Dapper.Query()` continua usando type maps globais quando a bridge estatica + e configurada; +- `QueryMapped*` estatico usa o runtime default publicado; +- profiles, converters, persistence metadata e generated materializers + continuam funcionando na bridge estatica e nos runtimes isolados. + +Compatibilidade binaria: + +- nao ha ferramenta de API compatibility configurada no repositorio atual; +- a auditoria de superficie publica nao identificou remocoes ou alteracoes de + assinatura nas APIs legadas; +- validacao binaria formal fica recomendada para a Etapa 12 antes de release. + +## Historical Issue #101 + +Estado final: + +```text +Issue #101: Partially resolved +``` + +Resolvida estruturalmente para APIs controladas pelo FluentMap: + +- configuracoes independentes no mesmo processo; +- runtimes com caches proprios; +- queries concorrentes para o mesmo tipo com mappings diferentes; +- DI com service providers independentes. + +Nao resolvida para caminhos globais legados: + +- `Dapper.Query()`; +- Dommel; +- troca global por reset/clear durante operacoes concorrentes. + +## Known Limitations + +- `Dapper.Query()` tradicional nao seleciona runtime por chamada. +- Dommel continua bridge process-wide. +- Assembly scanning depende de reflection discovery e nao e recomendado para + trimming/Native AOT. +- `QueryMapped*` ainda pode usar fallback runtime com reflection/dynamic code. +- Converter instances/delegates devem ser stateless ou thread-safe. +- Generated materializers por instancia/delegate ou shapes nao suportados ainda + usam fallback runtime. +- Named/keyed DI configurations nao foram adicionadas. +- As colecoes legadas `FluentMapper.EntityMaps` e `TypeConventions` continuam + mutaveis por compatibilidade e podem bypassar validacao/cache/type map. + +## Technical Debt + +- Adicionar ferramenta formal de API/binary compatibility antes de release. +- Planejar obsolescencia gradual das colecoes publicas mutaveis. +- Projetar bridge Dommel por runtime apenas se os extension points globais + permitirem um contrato honesto. +- Avaliar caminho generated-only/AOT-safe sem fallback runtime. +- Executar benchmark completo de release para cold start, muitos runtimes e + configuracoes grandes. +- Corrigir metadata NuGet legada do core (`licenseUrl`/README de pacote) em + tarefa propria. + +## Deferred Items + +- Named/keyed DI configurations. +- Full Native AOT support. +- Dommel configuration isolation. +- Public reset/clear API. +- Compatibility matrix ampla de releases. +- Provider certification completa. +- Release automation. +- Write converter execution em Dapper/Dommel. +- Service-based converter factories/lifetimes. + +## Recommendations for Etapa 12 + +1. Introduzir validacao formal de API/binary compatibility e baseline publica. +2. Decidir politica de obsolescencia para `FluentMapper.EntityMaps` e + `FluentMapper.TypeConventions`. +3. Avaliar um caminho generated-only/AOT-safe separado do fallback runtime. +4. Desenhar Dommel isolation somente se houver extension point viavel sem + `AsyncLocal` ou service locator. +5. Rodar benchmark completo de release antes de publicar pacote. + +## Validation + +Executado em 2026-07-29: + +```bash +dotnet restore ./Dapper.FluentMap.sln +dotnet build ./Dapper.FluentMap.sln --configuration Release --no-restore +dotnet test ./Dapper.FluentMap.sln --configuration Release --no-build +dotnet run --project ./benchmarks/Dapper.FluentMap.Benchmarks/Dapper.FluentMap.Benchmarks.csproj --configuration Release -- --filter "*MaterializationSteadyStateBenchmarks*QueryMappedSimple*" --job Dry +dotnet run --project ./test/Dapper.FluentMap.AotSmoke/Dapper.FluentMap.AotSmoke.csproj --configuration Release -p:DefineConstants=AOT_SMOKE_EXPLICIT -p:UseSharedCompilation=false +dotnet run --project ./test/Dapper.FluentMap.AotSmoke/Dapper.FluentMap.AotSmoke.csproj --configuration Release -p:DefineConstants=AOT_SMOKE_GENERATED -p:UseSharedCompilation=false +dotnet run --project ./test/Dapper.FluentMap.AotSmoke/Dapper.FluentMap.AotSmoke.csproj --configuration Release -p:DefineConstants=AOT_SMOKE_DI_EXPLICIT -p:UseSharedCompilation=false +dotnet run --project ./test/Dapper.FluentMap.AotSmoke/Dapper.FluentMap.AotSmoke.csproj --configuration Release -p:DefineConstants=AOT_SMOKE_DI_GENERATED -p:UseSharedCompilation=false +dotnet publish ./test/Dapper.FluentMap.AotSmoke/Dapper.FluentMap.AotSmoke.csproj --configuration Release -p:PublishTrimmed=true -p:DefineConstants=AOT_SMOKE_DI_EXPLICIT -p:UseSharedCompilation=false --output ./.tmp/aot-smoke/di-explicit-trimmed +./.tmp/aot-smoke/di-explicit-trimmed/Dapper.FluentMap.AotSmoke.exe +dotnet publish ./test/Dapper.FluentMap.AotSmoke/Dapper.FluentMap.AotSmoke.csproj --configuration Release -p:PublishTrimmed=true -p:DefineConstants=AOT_SMOKE_DI_GENERATED -p:UseSharedCompilation=false --output ./.tmp/aot-smoke/di-generated-trimmed +./.tmp/aot-smoke/di-generated-trimmed/Dapper.FluentMap.AotSmoke.exe +dotnet publish ./test/Dapper.FluentMap.AotSmoke/Dapper.FluentMap.AotSmoke.csproj --configuration Release -p:PublishAot=true -p:DefineConstants=AOT_SMOKE_DI_EXPLICIT -p:UseSharedCompilation=false --output ./.tmp/aot-smoke/di-explicit-aot +dotnet pack ./src/Dapper.FluentMap/Dapper.FluentMap.csproj --configuration Release --no-build --output ./artifacts/packages +dotnet pack ./src/Dapper.FluentMap.DependencyInjection/Dapper.FluentMap.DependencyInjection.csproj --configuration Release --no-build --output ./artifacts/packages +tar -tf ./artifacts/packages/Dapper.FluentMap.2.0.0.nupkg +tar -tf ./artifacts/packages/Dapper.FluentMap.DependencyInjection.2.0.0.nupkg +``` + +Resultados: + +- Restore: sucesso. +- Build Release: sucesso, 0 warnings, 0 errors. +- Solution tests: sucesso, 453 testes aprovados. +- Benchmark smoke: sucesso, 20 cenarios executados; warnings de iteracao curta + esperados do BenchmarkDotNet. +- AOT smoke executavel: `explicit:ok`, `generated:ok`, `di-explicit:ok`, + `di-generated:ok`. +- Trimmed DI explicit: publish e execucao com sucesso; warning conhecido + `IL2104` do Dapper. +- Trimmed DI generated: publish e execucao com sucesso; warnings conhecidos + `IL2104` de `Dapper.FluentMap`/Dapper. +- Native AOT: bloqueado pelo ambiente por ausencia de platform linker. +- Pack core: sucesso; warning legado `NU5125` sobre `licenseUrl` obsoleto e + aviso de README ausente no pacote. +- Pack DI: sucesso. +- Inspecao dos pacotes: assemblies e XML documentation em `lib/netstandard2.0`; + pacote DI inclui `README.md`; nenhum pacote contem projetos de teste. + +Observacao: uma tentativa inicial de smokes AOT em paralelo falhou com `CS2012` +no assembly intermediario do generator por disputa de arquivo. Os mesmos +smokes foram reexecutados sequencialmente com sucesso. diff --git a/.sdd/etapa-11/STATUS.md b/.sdd/etapa-11/STATUS.md index 74a3b3c..7478063 100644 --- a/.sdd/etapa-11/STATUS.md +++ b/.sdd/etapa-11/STATUS.md @@ -1,10 +1,15 @@ # Etapa 11 Status +Status: Concluída + +Último prompt executado: 11.7 + ## Objetivo -Definir discovery e arquitetura para Configuration Isolation & Dependency -Injection, preservando a API estatica historica como camada de compatibilidade -e preparando configuracoes imutaveis com runtime isolado. +Definir, implementar, auditar e encerrar Configuration Isolation & Dependency +Injection, preservando a API estatica historica como camada de compatibilidade, +introduzindo configuracoes imutaveis com runtime isolado e documentando limites +estruturais de Dapper/Dommel, trimming e Native AOT. ## Concluido @@ -65,17 +70,24 @@ e preparando configuracoes imutaveis com runtime isolado. - Adicionado teste que prova que Dommel resolve metadata pela configuracao legada process-wide e nao por runtimes isolados do core. - Atualizada a classificacao final da issue #101 como `Partially resolved`. - Repetido benchmark smoke de `MaterializationSteadyStateBenchmarks*QueryMappedSimple*` e registrado resultado no relatorio de performance. +- Executada auditoria final do prompt 11.7 sobre SDD, API publica, imutabilidade, isolamento, bridge estatica, DI, performance, trimming/AOT, Dommel e documentacao publica. +- Atualizado `README.md` com secoes explicitas de static configuration compatibility, isolated configuration, multiple configurations, test isolation e known limitations. +- Atualizado `05-performance-impact.md` com benchmark smoke do prompt 11.7. +- Criado `FINAL-REPORT.md` com matriz de requisitos, status da issue #101, validacoes e recomendacoes para Etapa 12. +- Executada validacao final obrigatoria de restore, build e tests da solution. +- Executados smokes de benchmark, AOT/trimming, pack e inspecao de pacotes. ## Em andamento -- Nenhum item em andamento para o prompt 11.6. +- Nenhum item em andamento. ## Proximos passos -1. Endurecer Dommel em design proprio, mantendo honestos os limites process-wide de `DommelMapper`. -2. Avaliar full benchmark antes de release. -3. Migrar gradualmente testes antigos de isolamento/concurrencia para runtime instanciado quando isso reduzir dependencia de reset global. -4. Estender smoke Native AOT para cobrir o pacote DI publicado com registro gerado, se a matriz de release exigir esse contrato. +1. Introduzir validacao formal de API/binary compatibility antes de release. +2. Decidir politica de obsolescencia gradual para colecoes publicas mutaveis legadas. +3. Avaliar caminho generated-only/AOT-safe em etapa propria. +4. Endurecer Dommel em design proprio, mantendo honestos os limites process-wide de `DommelMapper`. +5. Avaliar benchmark completo antes de release. ## Decisoes relevantes @@ -273,6 +285,28 @@ e preparando configuracoes imutaveis com runtime isolado. - `dotnet test .\Dapper.FluentMap.sln --configuration Release --no-build`: sucesso, 453 testes aprovados no total. - `dotnet pack`: nao executado; o prompt 11.6 alterou testes e documentacao SDD, sem mudanca de empacotamento, metadata de pacote ou assemblies de producao. +## Validacao do Prompt 11.7 + +- Detectado runner de testes como VSTest: SDK `10.0.302`, sem `global.json`, sem `Directory.Build.props`/`Directory.Packages.props` e projetos de teste com `Microsoft.NET.Test.Sdk` + xUnit runner. +- `dotnet restore ./Dapper.FluentMap.sln`: sucesso. +- `dotnet build ./Dapper.FluentMap.sln --configuration Release --no-restore`: sucesso, 0 warnings, 0 errors. +- `dotnet test ./Dapper.FluentMap.sln --configuration Release --no-build`: sucesso, 453 testes aprovados no total. +- `dotnet run --project .\benchmarks\Dapper.FluentMap.Benchmarks\Dapper.FluentMap.Benchmarks.csproj --configuration Release -- --filter "*MaterializationSteadyStateBenchmarks*QueryMappedSimple*" --job Dry`: sucesso; 20 cenarios executados; BenchmarkDotNet emitiu warnings esperados de iteracao curta. +- Tentativa inicial de smokes AOT em paralelo falhou com `CS2012` no assembly intermediario do generator por disputa de arquivo; os mesmos smokes foram reexecutados sequencialmente com sucesso. +- `dotnet run --project .\test\Dapper.FluentMap.AotSmoke\Dapper.FluentMap.AotSmoke.csproj --configuration Release -p:DefineConstants=AOT_SMOKE_EXPLICIT -p:UseSharedCompilation=false`: sucesso; `explicit:ok`. +- `dotnet run --project .\test\Dapper.FluentMap.AotSmoke\Dapper.FluentMap.AotSmoke.csproj --configuration Release -p:DefineConstants=AOT_SMOKE_GENERATED -p:UseSharedCompilation=false`: sucesso; `generated:ok`. +- `dotnet run --project .\test\Dapper.FluentMap.AotSmoke\Dapper.FluentMap.AotSmoke.csproj --configuration Release -p:DefineConstants=AOT_SMOKE_DI_EXPLICIT -p:UseSharedCompilation=false`: sucesso; `di-explicit:ok`. +- `dotnet run --project .\test\Dapper.FluentMap.AotSmoke\Dapper.FluentMap.AotSmoke.csproj --configuration Release -p:DefineConstants=AOT_SMOKE_DI_GENERATED -p:UseSharedCompilation=false`: sucesso; `di-generated:ok`. +- `dotnet publish .\test\Dapper.FluentMap.AotSmoke\Dapper.FluentMap.AotSmoke.csproj --configuration Release -p:PublishTrimmed=true -p:DefineConstants=AOT_SMOKE_DI_EXPLICIT -p:UseSharedCompilation=false --output .\.tmp\aot-smoke\di-explicit-trimmed`: sucesso; warning conhecido `IL2104` do Dapper. +- `.\.tmp\aot-smoke\di-explicit-trimmed\Dapper.FluentMap.AotSmoke.exe`: sucesso; `di-explicit:ok`. +- `dotnet publish .\test\Dapper.FluentMap.AotSmoke\Dapper.FluentMap.AotSmoke.csproj --configuration Release -p:PublishTrimmed=true -p:DefineConstants=AOT_SMOKE_DI_GENERATED -p:UseSharedCompilation=false --output .\.tmp\aot-smoke\di-generated-trimmed`: sucesso; warnings conhecidos `IL2104` de `Dapper.FluentMap`/Dapper. +- `.\.tmp\aot-smoke\di-generated-trimmed\Dapper.FluentMap.AotSmoke.exe`: sucesso; `di-generated:ok`. +- `dotnet publish .\test\Dapper.FluentMap.AotSmoke\Dapper.FluentMap.AotSmoke.csproj --configuration Release -p:PublishAot=true -p:DefineConstants=AOT_SMOKE_DI_EXPLICIT -p:UseSharedCompilation=false --output .\.tmp\aot-smoke\di-explicit-aot`: bloqueado pelo ambiente; erro `Platform linker not found`, exigindo prerequisites de Native AOT/Desktop Development for C++. +- `dotnet pack .\src\Dapper.FluentMap\Dapper.FluentMap.csproj --configuration Release --no-build --output .\artifacts\packages`: sucesso; warning legado `NU5125` sobre `licenseUrl` obsoleto e aviso de README ausente no pacote. +- `dotnet pack .\src\Dapper.FluentMap.DependencyInjection\Dapper.FluentMap.DependencyInjection.csproj --configuration Release --no-build --output .\artifacts\packages`: sucesso. +- Inspecionado `artifacts\packages\Dapper.FluentMap.2.0.0.nupkg`: contem nuspec/metadados e `lib/netstandard2.0/Dapper.FluentMap.dll` + XML; nao contem projetos de teste. +- Inspecionado `artifacts\packages\Dapper.FluentMap.DependencyInjection.2.0.0.nupkg`: contem `README.md`, `lib/netstandard2.0/Dapper.FluentMap.DependencyInjection.dll`, XML documentation e nuspec; nao contem projetos de teste. + ## Ultimo prompt executado -Ultimo prompt executado: 11.6 +Ultimo prompt executado: 11.7 diff --git a/README.md b/README.md index fe544c2..3fdc508 100644 --- a/README.md +++ b/README.md @@ -328,8 +328,30 @@ 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. -You can also build an immutable configuration snapshot without mutating the -global FluentMapper state: +### Static configuration - compatibility + +The historical static configuration remains supported: + +```csharp +FluentMapper.Initialize(config => +{ + config.AddMap(); +}); + +FluentMapper.Validate(); +``` + +This path publishes the default `ImmutableFluentMapConfiguration` and +`FluentMapRuntime` exposed by `FluentMapper.Configuration` and +`FluentMapper.Runtime`, and installs global Dapper type maps for default maps +and conventions. Use it when your process has one effective FluentMap +configuration and you want normal `connection.Query()` calls to use the +global Dapper type map bridge. + +### Isolated configuration + +You can build an immutable configuration snapshot without mutating the global +`FluentMapper` state: ```csharp using Dapper.FluentMap.Configuration; @@ -352,6 +374,57 @@ process. `FluentMapper.Initialize(...)` remains the global compatibility layer, with `FluentMapper.Configuration` and `FluentMapper.Runtime` exposing the currently published default configuration and runtime. +### Multiple configurations + +Multiple configurations are supported for FluentMap-controlled materialization +when each operation uses the intended `FluentMapRuntime`: + +```csharp +var current = new FluentMapConfigurationBuilder() + .AddMap() + .Build() + .CreateRuntime(); + +var legacy = new FluentMapConfigurationBuilder() + .AddMap() + .Build() + .CreateRuntime(); + +var currentCustomer = current.QueryMappedSingle( + connection, + "SELECT 1 AS customer_id, 'Ada' AS customer_name;"); + +var legacyCustomer = legacy.QueryMappedSingle( + connection, + "SELECT 2 AS customer_id, 'Grace' AS legacy_name;"); +``` + +This isolation applies to `QueryMapped*`, `ReadMapped*`, +`QueryMultipleMapped`, profiles, converters, generated materializers and +runtime diagnostics. It does not make normal `Dapper.Query()` or Dommel +select a FluentMap runtime per call. + +### Test isolation + +Tests can create a local builder and runtime instead of resetting global +FluentMap state: + +```csharp +var runtime = new FluentMapConfigurationBuilder() + .AddMap() + .Build() + .CreateRuntime(); + +var customer = runtime.QueryMappedSingle( + connection, + "SELECT 42 AS customer_id, 'Grace' AS Name;"); +``` + +This lets tests use different mappings for the same entity type in the same +process. Tests that exercise `FluentMapper.Initialize(...)`, direct +`FluentMapper.EntityMaps` mutation, normal Dapper type maps or Dommel still +touch process-wide state and should remain isolated accordingly. + ## Dependency Injection Install `Dapper.FluentMap.DependencyInjection` when using ASP.NET Core, Worker @@ -779,6 +852,8 @@ persistence behavior that matches the intent: `ReadOnly()`, `Computed()`, ## Current Limitations - `FluentMapper.Initialize(...)`, `Dapper.Query()` and Dommel still use process-wide compatibility bridges. Use `ImmutableFluentMapConfiguration` + `FluentMapRuntime` with `QueryMapped*`/`ReadMapped*` when multiple configurations must coexist in the same process. +- Multiple configurations are isolated only for FluentMap-controlled materialization. Normal `Dapper.Query()` uses the global `SqlMapper.SetTypeMap` registered for the entity type. +- Dommel integration uses global `DommelMapper` resolvers/builders and reads the legacy process-wide FluentMap collections; isolated core runtimes do not configure Dommel. - Assembly scanning depends on reflection discovery and is not the recommended path for trimmed or Native AOT applications. - `QueryMapped*` may use generated materializers for supported flat, nested and Value Object shapes, but it can still fall back to runtime metadata and dynamic code; it is not yet a guaranteed Native AOT-safe materialization path. - Property converters are not a general object mapper, serializer, SQL hook or replacement for Dapper `TypeHandler`. @@ -1142,6 +1217,28 @@ 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. +### Configuração Estática - Compatibilidade + +A configuração estática histórica continua suportada: + +```csharp +FluentMapper.Initialize(config => +{ + config.AddMap(); +}); + +FluentMapper.Validate(); +``` + +Esse caminho publica o `ImmutableFluentMapConfiguration` e o +`FluentMapRuntime` default expostos por `FluentMapper.Configuration` e +`FluentMapper.Runtime`, e instala type maps globais do Dapper para maps default +e conventions. Use esse caminho quando o processo possui uma única configuração +FluentMap efetiva e você quer que chamadas normais a `connection.Query()` +usem a bridge global de type map do Dapper. + +### Configuração Isolada + Tambem e possivel construir um snapshot imutavel sem alterar o estado global do `FluentMapper`: @@ -1166,6 +1263,57 @@ mesmo processo. `FluentMapper.Initialize(...)` continua sendo a camada global de compatibilidade, com `FluentMapper.Configuration` e `FluentMapper.Runtime` expondo a configuracao e o runtime default publicados. +### Múltiplas Configurações + +Múltiplas configurações são suportadas para materialização controlada pelo +FluentMap quando cada operação usa o `FluentMapRuntime` correto: + +```csharp +var current = new FluentMapConfigurationBuilder() + .AddMap() + .Build() + .CreateRuntime(); + +var legacy = new FluentMapConfigurationBuilder() + .AddMap() + .Build() + .CreateRuntime(); + +var currentCustomer = current.QueryMappedSingle( + connection, + "SELECT 1 AS customer_id, 'Ada' AS customer_name;"); + +var legacyCustomer = legacy.QueryMappedSingle( + connection, + "SELECT 2 AS customer_id, 'Grace' AS legacy_name;"); +``` + +Esse isolamento vale para `QueryMapped*`, `ReadMapped*`, +`QueryMultipleMapped`, profiles, converters, materializadores gerados e +diagnósticos do runtime. Ele não faz `Dapper.Query()` normal nem Dommel +selecionarem um runtime FluentMap por chamada. + +### Isolamento de Testes + +Testes podem criar um builder e runtime locais em vez de resetar o estado +global do FluentMap: + +```csharp +var runtime = new FluentMapConfigurationBuilder() + .AddMap() + .Build() + .CreateRuntime(); + +var customer = runtime.QueryMappedSingle( + connection, + "SELECT 42 AS customer_id, 'Grace' AS Name;"); +``` + +Isso permite que testes usem mappings diferentes para o mesmo tipo de entidade +no mesmo processo. Testes que exercitam `FluentMapper.Initialize(...)`, +mutação direta de `FluentMapper.EntityMaps`, type maps normais do Dapper ou +Dommel ainda tocam estado process-wide e devem continuar isolados de acordo. + ## Dependency Injection Instale `Dapper.FluentMap.DependencyInjection` ao usar ASP.NET Core, Worker @@ -1594,6 +1742,8 @@ ainda devem ser lidos, use o persistence behavior correspondente: ## Limitações Atuais - `FluentMapper.Initialize(...)`, `Dapper.Query()` e Dommel continuam usando bridges globais/process-wide. Para multiplas configuracoes simultaneas no mesmo processo, use `ImmutableFluentMapConfiguration` + `FluentMapRuntime` com os entry points `QueryMapped*`/`ReadMapped*`. +- Múltiplas configurações são isoladas somente para materialização controlada pelo FluentMap. `Dapper.Query()` normal usa o `SqlMapper.SetTypeMap` global registrado para o tipo de entidade. +- A integração Dommel usa resolvers/builders globais do `DommelMapper` e lê as coleções legadas process-wide do FluentMap; runtimes isolados do core não configuram o Dommel. - Assembly scanning depende de descoberta por reflection e não é o caminho recomendado para aplicações com trimming ou Native AOT. - Property converters nao sao object mapper geral, serializer, hook de SQL nem substituto para `TypeHandler` do Dapper. - Write converters sao metadata-only na integracao Dommel atual e nao sao executados por `Insert` ou `Update`. From baf4d2fc9b903cfb05fc7909166498ebf429cd12 Mon Sep 17 00:00:00 2001 From: Rodrigo de Oliveira Date: Wed, 29 Jul 2026 10:35:17 -0300 Subject: [PATCH 36/49] docs(sdd): define release readiness criteria --- .sdd/etapa-12/01-release-readiness-audit.md | 327 ++++++++++++++++++++ .sdd/etapa-12/02-compatibility-spec.md | 250 +++++++++++++++ .sdd/etapa-12/DECISIONS.md | 263 ++++++++++++++++ .sdd/etapa-12/STATUS.md | 144 +++++++++ 4 files changed, 984 insertions(+) create mode 100644 .sdd/etapa-12/01-release-readiness-audit.md create mode 100644 .sdd/etapa-12/02-compatibility-spec.md create mode 100644 .sdd/etapa-12/DECISIONS.md create mode 100644 .sdd/etapa-12/STATUS.md diff --git a/.sdd/etapa-12/01-release-readiness-audit.md b/.sdd/etapa-12/01-release-readiness-audit.md new file mode 100644 index 0000000..0378930 --- /dev/null +++ b/.sdd/etapa-12/01-release-readiness-audit.md @@ -0,0 +1,327 @@ +# Release Readiness Audit + +Auditoria executada em 2026-07-29 no checkout local +`feature/etapa-3`, sem usar memoria de chats anteriores como fonte de verdade. + +## Repository State + +- Branch: `feature/etapa-3`, a frente de `origin/feature/etapa-3` por 14 commits. +- Worktree antes das alteracoes da Etapa 12: item nao rastreado preexistente + `src/Dapper.FluentMap/etapas/`. +- `.sdd/etapa-12/` nao existia e foi criada neste prompt. +- SDK local usado na auditoria: `10.0.302`. +- Nao ha `global.json`, `Directory.Build.props`, `Directory.Packages.props` ou + `.editorconfig` na raiz. +- `NuGet.Config` usa apenas `https://api.nuget.org/v3/index.json`. + +## Projects + +Projetos na solution: + +| Projeto | Tipo | TFM | Packable | Observacao | +| --- | --- | --- | --- | --- | +| `src/Dapper.FluentMap` | Core library | `netstandard2.0` | Sim | API principal e integracao Dapper. | +| `src/Dapper.FluentMap.Dommel` | Dommel integration | `netstandard2.0` | Sim | Bridge global com Dommel. | +| `src/Dapper.FluentMap.DependencyInjection` | DI integration | `netstandard2.0` | Sim | Pacote opcional para `IServiceCollection`. | +| `src/Dapper.FluentMap.Analyzers` | Roslyn analyzer | `netstandard2.0` | Sim | Empacotado em `analyzers/dotnet/cs`. | +| `src/Dapper.FluentMap.Generators` | Source generator | `netstandard2.0` | Sim | Empacotado em `analyzers/dotnet/cs`. | +| `test/Dapper.FluentMap.Tests` | Tests | `net10.0` | Nao | Core unit/integration/regression. | +| `test/Dapper.FluentMap.Dommel.Tests` | Tests | `net10.0` | Nao | Dommel integration/regression. | +| `test/Dapper.FluentMap.DependencyInjection.Tests` | Tests | `net10.0` | Nao | DI integration. | +| `test/Dapper.FluentMap.Analyzers.Tests` | Tests | `net10.0` | Nao | Analyzer tests. | +| `test/Dapper.FluentMap.Generators.Tests` | Tests | `net10.0` | Nao | Generator tests. | +| `test/Dapper.FluentMap.GeneratedRegistration.Tests` | Tests | `net10.0` | Nao | Generated registration integration. | +| `test/Dapper.FluentMap.AotSmoke` | Smoke app | `net10.0` | Nao | Trim/AOT smoke harness. | +| `benchmarks/Dapper.FluentMap.Benchmarks` | Benchmark app | `net10.0` | Nao | BenchmarkDotNet harness. | + +## Packages + +`dotnet pack ./Dapper.FluentMap.sln --configuration Release --no-build --output ./artifacts/packages` +produz atualmente: + +- `Dapper.FluentMap.2.0.0.nupkg` +- `Dapper.FluentMap.Dommel.2.0.0.nupkg` +- `Dapper.FluentMap.DependencyInjection.2.0.0.nupkg` +- `Dapper.FluentMap.Analyzers.2.0.0.nupkg` +- `Dapper.FluentMap.Generators.2.0.0.nupkg` + +Conteudo observado: + +- Core: `lib/netstandard2.0/Dapper.FluentMap.dll` e XML documentation. +- Dommel: `lib/netstandard2.0/Dapper.FluentMap.Dommel.dll` e XML documentation. +- DI: `README.md`, `lib/netstandard2.0/Dapper.FluentMap.DependencyInjection.dll` + e XML documentation. +- Analyzers: `README.md`, `analyzers/dotnet/cs/Dapper.FluentMap.Analyzers.dll`. +- Generators: `README.md`, `analyzers/dotnet/cs/Dapper.FluentMap.Generators.dll`. + +## Target Frameworks + +TFMs encontrados: + +- Public libraries and analyzer/generator packages: `netstandard2.0`. +- Tests, AOT smoke and benchmarks: `net10.0`. + +Nao ha multi-targeting real apesar de alguns projetos usarem +`TargetFrameworks` com somente `netstandard2.0`. + +## Dependency Matrix + +Dependencias diretas principais: + +| Projeto | Dependencias diretas | +| --- | --- | +| Core | `Dapper` `2.1.79`, `Microsoft.Bcl.AsyncInterfaces` `10.0.8` | +| Dommel | Core project reference, `Dapper` `2.1.79`, `Dommel` `3.5.3` | +| DI | Core project reference, `Microsoft.Extensions.DependencyInjection.Abstractions` `10.0.10` | +| Analyzers | `Microsoft.CodeAnalysis.CSharp` `5.6.0`, `Microsoft.CodeAnalysis.Analyzers` `5.6.0`, both `PrivateAssets=all` | +| Generators | `Microsoft.CodeAnalysis.CSharp` `5.6.0`, `Microsoft.CodeAnalysis.Analyzers` `5.6.0`, both `PrivateAssets=all` | +| Tests | `Microsoft.NET.Test.Sdk` `18.8.1`, `xunit.v3` `3.2.2`, `xunit.runner.visualstudio` `3.1.5`, SQLite packages | +| Benchmarks | `BenchmarkDotNet` `0.15.8`, SQLite packages | + +Dapper: + +- Versao minima atualmente permitida pelo pacote: `2.1.79`, porque a referencia + direta sem upper bound empacota dependencia NuGet minima `>= 2.1.79`. +- Versao usada nos testes: `2.1.79`. +- APIs publicas diretamente usadas: + - `SqlMapper.SetTypeMap` + - `SqlMapper.GetTypeMap` + - `SqlMapper.ExecuteReader` + - `SqlMapper.ExecuteReaderAsync` + - `SqlMapper.HasTypeHandler` + - `SqlMapper.ITypeMap` + - `SqlMapper.IMemberMap` + - `SqlMapper.TypeHandler` nos testes + - `DefaultTypeMap` + - `CommandDefinition` +- API sensivel: `SqlMapper.TypeHandlerCache.Parse(object)` e resolvida por + reflection interna em `DapperTypeHandlerAdapter`. Este e o maior risco de + compatibilidade com novas versoes do Dapper. + +Dommel: + +- Versao usada e minima atual: `3.5.3`. +- Pontos de integracao: + - `DommelMapper.SetColumnNameResolver` + - `DommelMapper.SetKeyPropertyResolver` + - `DommelMapper.SetTableNameResolver` + - `DommelMapper.SetPropertyResolver` + - `DommelMapper.AddSqlBuilder` + - `IColumnNameResolver`, `IKeyPropertyResolver`, `ITableNameResolver`, + `IPropertyResolver`, `ISqlBuilder` + - SQL builders padrao para SQL Server, SQL CE, SQLite, PostgreSQL e MySQL. +- Estado efetivo: bridge process-wide; runtimes isolados do core nao isolam + Dommel. + +Historico NuGet consultado via flat-container em 2026-07-29: + +- `Dapper.FluentMap` ja publicou ate `2.0.0` no pacote original. +- `Dapper.FluentMap.Dommel` ja publicou ate `2.0.0` no pacote original. +- `Dapper.FluentMap.DependencyInjection`, `Dapper.FluentMap.Analyzers` e + `Dapper.FluentMap.Generators` nao existem no NuGet.org nesse nome. + +## Test Coverage Categories + +| Categoria | Evidencia atual | +| --- | --- | +| Unit | Tests de mapping metadata, naming policies, member paths, validation, diagnostics, immutable configuration. | +| Integration | Traits `Category=Integration` em core, DI, generated registration, Dommel e SQLite real. | +| Generator | `Dapper.FluentMap.Generators.Tests` e `Dapper.FluentMap.GeneratedRegistration.Tests`. | +| Analyzer | `Dapper.FluentMap.Analyzers.Tests`; release manifest de regras existe, mas precisa limpeza. | +| Historical regression | `test/*/HistoricalRegression/*` cobre issues historicas das etapas 8 e 9. | +| AOT | Smoke project existe; Native AOT publish foi historicamente bloqueado no ambiente por ausencia de linker. | +| Trimming | Smokes trimmed registrados nas etapas 7, 10 e 11; warnings conhecidos `IL2104`/`IL2026` documentados. | +| Package | `dotnet pack` executado e conteudo de `.nupkg` inspecionado superficialmente. | +| Provider | SQLite em memoria e `DataTableReader`; SQL Server/PostgreSQL/MySQL/SQL CE tem builders Dommel configurados, mas nao certificados em CI. | +| Performance | BenchmarkDotNet harness existe; resultados anteriores sao smoke/guardrail, nao claims publicos. | + +Suite baseline neste prompt: + +- `dotnet test ./Dapper.FluentMap.sln --configuration Release --no-build` +- Resultado: 453 testes aprovados, 0 falhas, 0 ignorados. + +Observacao: alguns assemblies desabilitam paralelismo devido a estado global de +`FluentMapper`, Dapper e Dommel. + +## CI + +Workflows atuais: + +- `.github/workflows/ci.yml` + - triggers: push em `master` e `chore/net10-migration`, pull request. + - runner: `ubuntu-latest`. + - SDK: `10.0.x`, quality `ga`. + - passos: checkout, setup .NET, `dotnet --info`, restore, build Release, + test Release, pack Release, upload de `.nupkg`. + +Outros arquivos legados: + +- `.appveyor.yml` +- `.travis.yml` + +Lacunas de CI: + +- O workflow usa action versions `actions/checkout@v7`, `actions/setup-dotnet@v6` + e `actions/upload-artifact@v7`; estas versoes devem ser confirmadas antes de + tratar CI como operacional. +- Nao ha matriz de OS/SDK/provider. +- Nao ha etapa de API compatibility, package validation formal, SourceLink, + determinism check, signature verification, SBOM ou provenance. +- Nao ha workflow de release com gates separados de publish. + +## Packaging + +Estado atual: + +- Todos os pacotes usam `VersionPrefix=2.0.0`. +- Core e Dommel ainda usam `PackageLicenseUrl`, gerando `NU5125`. +- Core e Dommel nao incluem package README, e o pack emite aviso de best + practices. +- DI, Analyzers e Generators usam `PackageLicenseExpression=MIT` e README. +- Project URLs ainda apontam para o repositorio original + `https://github.com/henkmollema/Dapper-FluentMap`. +- Nao ha `RepositoryUrl`, `RepositoryType`, `PackageIcon`, symbol package, + SourceLink configurado ou metadata explicita de commit. +- Nao ha `ContinuousIntegrationBuild`, `Deterministic`, `EmbedUntrackedSources` + ou `PublishRepositoryUrl` configurados. + +## Public API + +Projetos que expoem API publica: + +- `Dapper.FluentMap` +- `Dapper.FluentMap.Dommel` +- `Dapper.FluentMap.DependencyInjection` +- `Dapper.FluentMap.Analyzers` +- `Dapper.FluentMap.Generators` + +Superficies publicas relevantes: + +- Core static/global: `FluentMapper`, `FluentMapConfiguration`, conventions, + mappings, type maps e helpers `QueryMapped*`. +- Core isolated runtime: `FluentMapConfigurationBuilder`, + `ImmutableFluentMapConfiguration`, `FluentMapRuntime`. +- Mapping metadata: persistence, conversion, generated materializer contracts, + diagnostics/explanations. +- Dommel: `DommelEntityMap`, `DommelPropertyMap`, `ForDommel()` e resolvers + publicos. +- DI: `IServiceCollection.AddFluentMap(...)`. +- Analyzer/generator: tipos publicos `DiagnosticAnalyzer` e + `IIncrementalGenerator`, diagnostic IDs `DFM001`-`DFM015`. + +Lacuna: nao ha baseline formal de API publica nem API/binary compatibility +tooling configurado. + +## Documentation + +Documentacao existente: + +- `README.md` cobre core, Dommel, analyzers, generators, DI, QueryMapped, + QueryMultipleMapped, streaming, converters, limitations e trimming/AOT. +- README dos pacotes DI, analyzers e generators existe. +- SDD das etapas 7-11 contem final reports e decisoes. + +Lacunas: + +- Core e Dommel nao incluem README no pacote. +- Nao ha matriz publica consolidada de compatibilidade por package/provider/TFM. +- Nao ha migration guide final para uma release maior do fork. +- Nao ha release checklist publico ou support policy. +- Metadados NuGet ainda apontam para o repositorio original. + +## AOT / Trimming + +Estado atual: + +- APIs de assembly scanning estao anotadas como sensiveis a trimming. +- `QueryMapped*`, `ReadMapped*`, `QueryMultipleMapped` e streaming permanecem + anotados com `RequiresUnreferencedCode` e `RequiresDynamicCode`, porque podem + cair no fallback runtime baseado em reflection/dynamic code. +- `Dapper.FluentMap.AotSmoke` cobre cenarios explicitos, gerados e DI. +- Smokes trimmed das etapas anteriores passaram com warnings conhecidos. +- Native AOT publish/run nao foi validado localmente por falta de toolchain + nativa. +- Nenhum projeto publico declara `IsAotCompatible`. + +Conclusao: a biblioteca possui caminhos preferenciais para trimmed apps, mas +nao esta release-ready para claim de Native AOT completo. + +## Security / Supply Chain + +Estado observado: + +- `dotnet list ./Dapper.FluentMap.sln package --vulnerable --include-transitive` + nao encontrou pacotes vulneraveis nas fontes atuais. +- `NuGet.Config` limpa feeds e usa apenas NuGet.org. +- Nao ha lock file de pacotes. +- Nao ha signing, SBOM, SLSA/provenance, checksum validation, dependency review + workflow ou scan de segredos configurado. +- Nao ha SourceLink nem deterministic build/provenance configurados. + +## Release Process + +Estado atual: + +- CI constroi, testa, empacota e publica artefatos, mas nao publica no NuGet. +- Nao ha processo documentado de release candidate, assinatura, validacao de + pacote, changelog, migration guide, tags ou rollback. +- Nao ha criterio documentado para quando warnings conhecidos sao aceitaveis. + +Release criteria propostos: + +- Restore, build Release e tests da solution passam. +- Pacotes packable corretos sao gerados e inspecionados. +- Warnings de pack/build sao zero ou todos explicados e aceitos em `STATUS.md`. +- API publica e compatibilidade binaria sao comparadas contra baseline aprovado. +- NuGet metadata esta atualizada para o fork, license/readme validos. +- README e package READMEs refletem comportamento real. +- Matriz de compatibilidade e provider certification/limitation estao + documentadas. +- Smokes trimming rodam em CI; Native AOT so e declarado quando houver publish + e execucao em ambiente com toolchain nativa. +- Nenhum pacote vulneravel conhecido no lock atual. +- Release candidate e validado antes de release estavel. + +## Identified Gaps + +### Critical + +- Nao ha validacao formal de API/binary compatibility. Para uma biblioteca + publica com muitas APIs novas desde a linha historica, isso bloqueia release + estavel. +- Versao `2.0.0` ja existe no NuGet.org para core e Dommel; publicar este fork + como `2.0.0` estavel nesses package IDs seria tecnicamente e operacionalmente + inseguro. + +### High + +- Metadata NuGet do core e Dommel esta incompleta/legada: `PackageLicenseUrl` + obsoleto, sem package README, project URL apontando para upstream original. +- Nao ha SourceLink, repository metadata, deterministic CI policy ou symbol + package para depurabilidade/reprodutibilidade. +- CI nao possui matriz minima de SDK/OS nem valida smokes trimming/AOT. +- Dapper compatibility depende de internals por reflection para + `TypeHandlerCache.Parse(object)`, sem matriz contra versoes futuras. +- Provider support nao esta separado de provider certification; SQLite e + provider-independent sao validados, outros providers nao. + +### Medium + +- Nao ha `global.json`; builds locais podem variar com SDK instalado. +- Nao ha Central Package Management ou lock file; aceitavel para repo pequeno, + mas piora auditabilidade de dependencias. +- Analyzer release manifests precisam revisao antes de publicar; ha regras + unshipped que parecem pertencer ao release planejado. +- Nao ha package validation formal (`dotnet package validation`/ApiCompat), + NuGet verify, assinatura ou SBOM. +- Native AOT permanece parcialmente validado e nao deve ser usado como claim de + release. + +### Low + +- Alguns projetos usam `TargetFrameworks` com um unico TFM. +- `.appveyor.yml` e `.travis.yml` parecem legados e podem confundir leitores. +- Testes desabilitam paralelismo em alguns assemblies por estado global; isso e + conhecido, mas deve permanecer documentado. +- Benchmarks existentes sao uteis como smoke, mas nao como promessa publica de + throughput. diff --git a/.sdd/etapa-12/02-compatibility-spec.md b/.sdd/etapa-12/02-compatibility-spec.md new file mode 100644 index 0000000..40d419c --- /dev/null +++ b/.sdd/etapa-12/02-compatibility-spec.md @@ -0,0 +1,250 @@ +# Compatibility Specification + +Esta especificacao define a politica pretendida para uma release futura. Ela +nao muda contratos, versoes ou targets neste prompt. + +## .NET TFMs + +Politica proposta: + +- Pacotes publicos continuam suportando `netstandard2.0` como TFM minimo ate + decisao explicita de major/compatibilidade. +- Testes, smoke apps e benchmarks podem continuar em `net10.0` para validar em + runtime moderno sem elevar o requisito dos consumidores. +- Multi-targeting de bibliotecas so deve ser adicionado se houver motivo + concreto: analyzers de trimming/AOT, APIs condicionais, performance ou + compatibilidade mensuravel. +- Se um TFM moderno for adicionado futuramente, ele deve ser aditivo + (`netstandard2.0;net8.0` ou superior), nao substitutivo. + +## Dapper + +Politica proposta: + +- Suporte minimo atual: `Dapper >= 2.1.79`. +- A matriz de release deve validar pelo menos: + - a versao minima suportada; + - a versao mais recente estavel de Dapper aprovada para a release; + - cenarios de type map, constructor mapping, `CommandDefinition`, + `ExecuteReader`, `ExecuteReaderAsync` e TypeHandler. +- Uso de APIs publicas do Dapper e permitido. +- Uso de internals por reflection, hoje `SqlMapper.TypeHandlerCache.Parse`, + deve ser tratado como risco de compatibilidade e coberto por teste dedicado. +- Se uma versao futura de Dapper quebrar esse boundary, a biblioteca deve falhar + com diagnostico claro e documentar a faixa suportada. + +## Dommel + +Politica proposta: + +- Suporte minimo atual: `Dommel >= 3.5.3`. +- Dommel e pacote opcional e nao faz parte do contrato do core. +- A integracao Dommel atual e process-wide porque usa extension points globais + de `DommelMapper`. +- Provider builders registrados: SQL Server, SQL CE, SQLite, PostgreSQL e MySQL. + Isso e provider support por integracao de builder, nao provider certification. +- Certification de provider exige teste real em CI ou harness documentado. + +## Providers + +Politica proposta: + +- Provider-certified hoje: SQLite para testes automatizados locais/CI. +- Provider-independent: `DataTableReader` e interfaces ADO.NET para materializacao. +- Provider-supported-but-not-certified: SQL Server, SQL CE, PostgreSQL e MySQL + via Dommel builders existentes. +- Documentacao publica deve diferenciar: + - supported by design; + - covered by automated tests; + - manually smoke-tested; + - not certified. + +## Source compatibility + +Politica proposta: + +- Preservar nomes publicos, namespaces, generic constraints, overloads e + comportamento observavel das APIs historicas sempre que possivel. +- Novas APIs devem ser aditivas. +- `FluentMapper.Initialize`, `FluentMapper.EntityMaps`, + `FluentMapper.TypeConventions`, `EntityMap`, `PropertyMap`, conventions e + type maps historicos permanecem compatibilidade legada. +- Marcacoes `[Obsolete]` futuras devem ser documentadas e nao remover API no + mesmo release menor. +- Mudancas de validacao que rejeitam configuracoes contraditorias podem ser + tratadas como bugfix, mas precisam teste de regressao e nota de migracao. + +## Binary compatibility + +Politica proposta: + +- Release estavel exige baseline binaria formal para os pacotes publicos. +- Remover tipo/membro publico, alterar assinatura, alterar generic constraint, + mudar tipo de retorno publico ou trocar tipo base/interface publica e breaking + change. +- Adicionar membro abstrato ou alterar interface publica existente e breaking + change. +- Interfaces novas devem ser aditivas; preferir interfaces auxiliares para + metadata nova, como ja foi feito com persistence/conversion. +- O baseline deve cobrir core, Dommel, DI, analyzers e generators. + +## Analyzer compatibility + +Politica proposta: + +- IDs `DFM001`-`DFM015` sao parte do contrato de usuario quando publicados. +- Severity padrao e categoria devem ser estaveis dentro de uma major, salvo bug + claro. +- Novas regras devem ser registradas nos manifests Roslyn corretos. +- Regras que podem gerar falsos positivos relevantes devem iniciar como Info ou + Warning, nao Error, salvo quando o erro for estaticamente provavel. +- Analyzer package nao deve expor dependencias Roslyn transitivas. + +## Generator compatibility + +Politica proposta: + +- `AddGeneratedMappings()` emitido e contrato publico gerado e deve permanecer + source-compatible dentro da major. +- Generated materialization e otimizacao. Fallback runtime deve continuar + preservando comportamento quando um map/shape nao e suportado. +- Diagnostics de fallback devem ser informativos e nao quebrar builds por + padrao. +- O generator nao deve executar construtores de maps, acessar banco, parsear SQL + ou scanear assemblies referenciados. + +## Native AOT + +Politica proposta: + +- Nao declarar suporte Native AOT completo no estado atual. +- Declaracoes permitidas: + - registro explicito e gerado sao os caminhos preferenciais para apps Native + AOT/trimming; + - assembly scanning e APIs `QueryMapped*`/`ReadMapped*` podem ser sensiveis a + trimming/dynamic code; + - generated materializers reduzem reflection no hot path, mas nao eliminam o + fallback. +- Claim Native AOT so sera permitido apos publish e execucao em CI com + toolchain nativa instalada e sem warnings inexplicados. + +## Trimming + +Politica proposta: + +- APIs conhecidamente sensiveis devem permanecer anotadas com + `RequiresUnreferencedCode` e/ou `RequiresDynamicCode`. +- Smokes `PublishTrimmed=true` devem ser parte da matriz de release para + registro explicito, registro gerado e DI. +- Warnings conhecidos de dependencias, como `IL2104` em Dapper, devem ser + documentados por versao e nao escondidos. +- Nao usar suppressions para simular compatibilidade. + +## Package compatibility + +Politica proposta: + +- Package IDs historicos devem ser tratados com cuidado especial: + `Dapper.FluentMap` e `Dapper.FluentMap.Dommel` ja possuem historico publico. +- Pacotes novos (`DependencyInjection`, `Analyzers`, `Generators`) precisam de + pre-release antes de estabilidade. +- Pacotes devem conter README quando aplicavel, license expression, repository + metadata, SourceLink/symbols quando configurados e dependencias corretas. +- Analyzer/generator packages devem suprimir dependencias transitivas e conter + assembly em `analyzers/dotnet/cs`. + +## Semantic Versioning + +Politica proposta: + +- Patch: bugfix compativel, documentacao, validacao de pacote sem mudanca de + contrato. +- Minor: API aditiva compativel. +- Major: breaking source/binary behavior ou mudanca relevante de contrato. +- Pre-release: obrigatorio para release candidate deste fork antes de qualquer + stable, devido ao salto de superficie publica e divergencia do pacote + original. + +## Deprecation + +Politica proposta: + +- Deprecation deve ter: + - alternativa documentada; + - motivo claro; + - janela minima de um minor/pre-release antes da remocao; + - testes mantendo o comportamento antigo enquanto a API existir. +- Candidatos futuros: + - mutacao direta de `FluentMapper.EntityMaps`; + - mutacao direta de `FluentMapper.TypeConventions`; + - assembly scanning em cenarios trimmed/AOT, com alternativa explicita/gerada. + +## Breaking changes + +Politica proposta: + +- Qualquer breaking change exige ADR, teste, migration guide e major/pre-release. +- Correcoes de bug com mudanca comportamental devem ser chamadas pelo nome e + vinculadas ao comportamento incorreto anterior. +- Breaking changes proibidas sem decisao explicita: + - elevar TFM minimo do core; + - remover APIs estaticas historicas; + - remover Dommel process-wide sem alternativa; + - trocar package IDs; + - alterar semantics de `Ignore()`, persistence metadata, profiles ou + precedence mapping sem teste e migracao. + +## Release Criteria + +Uma release so deve ser considerada pronta quando: + +- `dotnet restore ./Dapper.FluentMap.sln` passa. +- `dotnet build ./Dapper.FluentMap.sln --configuration Release --no-restore` + passa. +- `dotnet test ./Dapper.FluentMap.sln --configuration Release --no-build` + passa. +- Pacotes packable corretos sao gerados e inspecionados. +- Warnings de build/pack sao zero ou explicitamente aceitos como conhecidos. +- API/binary compatibility passa contra baseline aprovado. +- NuGet metadata esta correta para o fork e para cada pacote. +- README e package READMEs correspondem ao comportamento real. +- Migration guide e compatibility matrix existem. +- Provider support/certification esta documentado. +- Smokes trimming rodam e warnings conhecidos estao documentados. +- Native AOT so aparece como suportado se houver publish/run validado. +- Vulnerability audit nao aponta vulnerabilidades conhecidas sem triagem. +- Release candidate passou por validacao antes de stable. + +## Version Strategy Recommendation + +Recomendacao tecnica: + +- Nao publicar este estado como `2.0.0` estavel. +- Para package IDs historicos, usar uma nova linha pre-release do fork, por + exemplo `3.0.0-rc.1` se a decisao for assumir que as evolucoes acumuladas + exigem nova major, ou `2.1.0-rc.1` apenas se ApiCompat provar compatibilidade + forte com `2.0.0`. +- Pela superficie publica acumulada e pela ausencia atual de ApiCompat, a opcao + mais segura e `3.0.0-rc.1`. +- Para pacotes novos (`DependencyInjection`, `Analyzers`, `Generators`), tambem + usar pre-release alinhado ao core antes de stable. +- Nao alterar versao neste prompt; a decisao final deve ocorrer apos + compatibility baseline e pacote validado. + +## Incremental Plan + +1. Compatibility matrix: definir TFMs, Dapper, Dommel, providers e baselines de + API/binario por pacote. +2. Provider validation: manter SQLite como certificado inicial e decidir se + SQL Server/PostgreSQL/MySQL entram em CI ou permanecem support-by-design. +3. Public API/package hardening: adicionar ApiCompat/package validation, + corrigir NuGet metadata, README de pacote, repository metadata, SourceLink e + symbols. +4. CI/release engineering: ajustar workflow, adicionar matrix minima, + vulnerability audit, pack validation, trimming smoke e artefatos. +5. Documentation/migration/support policies: publicar matriz de compatibilidade, + migration guide, support policy e known limitations. +6. Release candidate validation: gerar RC, instalar em consumer smoke e validar + Dapper/Dommel/provider matrix. +7. Final audit: repetir build/test/pack, revisar diff, atualizar STATUS e + liberar apenas com blockers zerados ou aceitos. diff --git a/.sdd/etapa-12/DECISIONS.md b/.sdd/etapa-12/DECISIONS.md new file mode 100644 index 0000000..ecb854a --- /dev/null +++ b/.sdd/etapa-12/DECISIONS.md @@ -0,0 +1,263 @@ +# Etapa 12 Decisions + +## ADR-1 - TFMs oficialmente suportados + +### Contexto + +Os pacotes publicos atuais targetam `netstandard2.0`; testes, AOT smoke e +benchmarks targetam `net10.0`. O core deve preservar compatibilidade ampla. + +### Decisao + +Manter `netstandard2.0` como TFM oficial minimo dos pacotes publicos ate nova +ADR. Runtimes modernos continuam sendo usados em testes. + +### Alternativas consideradas + +- Elevar tudo para `net10.0`: rejeitado por breaking change desnecessaria. +- Multi-targeting imediato: adiado ate haver beneficio validado. + +### Consequencias + +Consumidores existentes continuam cobertos. Claims de AOT/trimming modernos +exigem smokes separados porque `netstandard2.0` nao habilita tudo sozinho. + +## ADR-2 - Politica de Dapper compatibility + +### Contexto + +O pacote referencia Dapper `2.1.79` e usa APIs publicas de type map/reader, mas +tambem acessa `SqlMapper.TypeHandlerCache.Parse(object)` por reflection. + +### Decisao + +Declarar `Dapper >= 2.1.79` como minimo atual e exigir matriz de release contra +a versao minima e uma versao estavel atual aprovada. O boundary de TypeHandler +deve ter teste dedicado e diagnostico claro. + +### Alternativas consideradas + +- Assumir qualquer Dapper 2.x como compativel: rejeitado sem matriz. +- Remover TypeHandler interoperability: breaking change e fora do escopo. + +### Consequencias + +Dapper vira parte central da matriz de release. Internals usados por reflection +sao risco conhecido, nao promessa irrestrita. + +## ADR-3 - Provider support vs provider certification + +### Contexto + +SQLite e `DataTableReader` sao validados. Dommel registra builders para varios +providers, mas CI nao executa bancos externos. + +### Decisao + +Separar "supported by design" de "certified by automated tests". SQLite e +provider-independent sao certificados inicialmente; demais providers ficam +documentados como nao certificados ate haver harness real. + +### Alternativas consideradas + +- Declarar todos os providers como certificados: rejeitado por falta de teste. +- Remover builders Dommel: breaking change sem necessidade. + +### Consequencias + +A documentacao fica honesta e evita claims maiores do que a evidencia. + +## ADR-4 - Binary compatibility + +### Contexto + +As etapas anteriores adicionaram muitas APIs publicas. Nao ha ferramenta formal +de API/binary compatibility configurada. + +### Decisao + +Release estavel exige baseline formal de API/binario para core, Dommel, DI, +analyzers e generators. + +### Alternativas consideradas + +- Confiar em revisao manual: insuficiente para biblioteca publica. +- Aceitar compatibilidade apenas source: insuficiente para consumidores NuGet. + +### Consequencias + +ApiCompat/package validation entram como blocker antes de stable. + +## ADR-5 - Package validation + +### Contexto + +`dotnet pack` passa, mas core/Dommel geram `NU5125` e aviso de README ausente. +Nao ha SourceLink, symbols ou repository metadata. + +### Decisao + +Pacotes devem passar pack validation sem warnings inexplicados, conter metadata +NuGet moderna e ter conteudo inspecionado antes de release. + +### Alternativas consideradas + +- Aceitar warnings legados para release inicial: permitido apenas para RC + interna, nao para stable publica. + +### Consequencias + +Core/Dommel precisam de hardening de metadata antes da release. + +## ADR-6 - Version strategy + +### Contexto + +NuGet.org ja possui `Dapper.FluentMap` e `Dapper.FluentMap.Dommel` `2.0.0`. +O fork adicionou grande superficie publica e novos pacotes. + +### Decisao + +Nao publicar como `2.0.0` estavel. A recomendacao inicial e `3.0.0-rc.1` para +uma linha do fork, salvo se ApiCompat provar que `2.1.0-rc.1` e seguro. + +### Alternativas consideradas + +- Continuar `2.0.0`: rejeitado porque a versao ja existe publicamente. +- Publicar `2.1.0` stable imediatamente: rejeitado sem compatibilidade formal. + +### Consequencias + +A estrategia final depende de baseline, mas o caminho seguro inicial e RC. + +## ADR-7 - Release candidate strategy + +### Contexto + +Ha muitas capacidades novas, pacotes novos e claims parciais de trimming/AOT. + +### Decisao + +Toda release publica deve passar primeiro por RC com pacotes instalados em +consumer smoke externo ou amostra de consumo. + +### Alternativas consideradas + +- Stable direto apos suite verde: rejeitado por risco de package/API. + +### Consequencias + +Release engineering deve incluir validacao de instalacao e consumo, nao so pack. + +## ADR-8 - Warning policy + +### Contexto + +Build Release esta limpo. Pack emite warnings NuGet para core/Dommel. Trimming +historico emite warnings conhecidos. + +### Decisao + +Build/test/pack de release devem ter zero warnings ou lista explicita de +warnings aceitos com motivo e escopo. Warnings de pacote metadata nao devem +permanecer em stable. + +### Alternativas consideradas + +- Tratar todo warning como fatal agora: adiado ate os warnings conhecidos serem + classificados. +- Ignorar warnings de pack: rejeitado para release publica. + +### Consequencias + +Warnings conhecidos podem existir em RC apenas se documentados; stable deve +reduzir o maximo possivel. + +## ADR-9 - AOT/trimming claims + +### Contexto + +Smokes trimmed passaram em etapas anteriores, mas Native AOT publish/run foi +bloqueado por falta de linker. APIs importantes seguem anotadas com +`RequiresUnreferencedCode` e `RequiresDynamicCode`. + +### Decisao + +Nao declarar Native AOT completo. Declarar apenas caminhos preferenciais e +limitacoes: registro explicito/gerado para trimming, scanning sensivel e +`QueryMapped*` com fallback runtime sensivel. + +### Alternativas consideradas + +- Declarar AOT-safe por causa de generated materializers: rejeitado por fallback. +- Remover fallback para obter claim AOT: breaking change e fora do escopo. + +### Consequencias + +Documentacao e NuGet metadata devem evitar claims amplos de AOT. + +## ADR-10 - Dependency versioning + +### Contexto + +Versoes estao nos `.csproj`, sem CPM ou lock file. O repo e pequeno, mas a +release precisa auditabilidade. + +### Decisao + +Nao migrar para Central Package Management neste prompt. Antes da release, +decidir entre manter versoes locais com auditoria simples ou adotar CPM/lock em +tarefa propria. + +### Alternativas consideradas + +- Adotar CPM imediatamente: fora do escopo documental. +- Atualizar dependencias junto com release readiness: rejeitado por misturar + riscos. + +### Consequencias + +Matriz de dependencias deve ser documentada e auditada a cada RC. + +## ADR-11 - Release automation + +### Contexto + +CI atual restaura, compila, testa, empacota e faz upload de artefatos. Nao ha +publish NuGet automatico. + +### Decisao + +Manter publish manual/ausente ate gates de release estarem definidos. Automatizar +primeiro build/test/pack/validation/provenance; publish so depois de aprovacao +explicita. + +### Alternativas consideradas + +- Adicionar publish ao workflow atual: rejeitado por falta de gates. + +### Consequencias + +Nao ha risco de publicacao acidental nesta etapa, mas release final requer +workflow dedicado. + +## ADR-12 - Support policy + +### Contexto + +O fork evoluiu uma biblioteca publica arquivada, com APIs legadas e novas. + +### Decisao + +Documentar suporte por package/TFM/provider e manter escopo do core como +mapping/materializacao para Dapper. Recursos fora do escopo, como ORM/CRUD/SQL +generator/provider universal, permanecem nao suportados. + +### Alternativas consideradas + +- Prometer suporte amplo por implicacao do README: rejeitado. + +### Consequencias + +Known limitations e compatibility matrix viram artefatos obrigatorios da +release. diff --git a/.sdd/etapa-12/STATUS.md b/.sdd/etapa-12/STATUS.md new file mode 100644 index 0000000..54f662c --- /dev/null +++ b/.sdd/etapa-12/STATUS.md @@ -0,0 +1,144 @@ +# Etapa 12 Status + +## Objetivo + +Transformar o estado atual da biblioteca em uma entrega buildable, testable, +compatible, packable, documented, reproducible e release-ready, sem adicionar +features. + +## Estado geral + +Etapa 12 iniciada com auditoria documental e baseline de build/test/pack. A +solution esta buildable e testable no ambiente local, mas ainda nao esta +release-ready por lacunas de API compatibility, versionamento, NuGet metadata, +SourceLink/reproducibilidade e CI de release. + +## Concluido + +- Executado `git status` antes de alteracoes. +- Lido `README.md`. +- Examinada `Dapper.FluentMap.sln`. +- Examinados projetos core, Dommel, DependencyInjection, Analyzers, + Generators, tests, AOT smoke e benchmarks. +- Examinados `NuGet.Config`, `.csproj`, workflow GitHub e ausencia de + `Directory.Build.props`, `Directory.Packages.props`, `global.json` e + `.editorconfig`. +- Lidos `.sdd/etapa-11/FINAL-REPORT.md` e `.sdd/etapa-11/STATUS.md`. +- Lidos `FINAL-REPORT.md` das etapas 7, 8, 9 e 10. +- Confirmado que `.sdd/etapa-12/` nao existia e criada a pasta. +- Criado `01-release-readiness-audit.md`. +- Criado `02-compatibility-spec.md`. +- Criado `DECISIONS.md`. +- Criado este `STATUS.md`. +- Executada validacao inicial obrigatoria de restore/build/test/pack. +- Executada auditoria de vulnerabilidades via NuGet. +- Consultado historico NuGet dos package IDs relevantes. + +## Em andamento + +- Nenhuma implementacao em andamento. Este prompt foi limitado a auditoria, + especificacao, decisoes e status. + +## Proximos passos + +1. Criar compatibility matrix formal por pacote, TFM, Dapper, Dommel e provider. +2. Adicionar baseline e tooling de API/binary compatibility. +3. Endurecer metadata de pacote, README de pacote, repository metadata, + SourceLink, symbols e package validation. +4. Ajustar CI para matriz minima e validar pack/trimming/compatibility. +5. Documentar migration guide, support policy e provider certification. +6. Definir e validar release candidate antes de stable. +7. Fazer auditoria final de release blockers. + +## Release blockers + +- Critical: nao ha validacao formal de API/binary compatibility. +- Critical: `2.0.0` ja existe no NuGet.org para core e Dommel; a estrategia de + versionamento do fork precisa mudar antes de publicar. +- High: core e Dommel geram `NU5125` e aviso de README ausente no pack. +- High: NuGet metadata ainda aponta para o repositorio upstream original. +- High: nao ha SourceLink, repository metadata, symbols ou deterministic CI + policy. +- High: CI nao valida matriz de provider/Dapper/SDK nem smokes trimming/AOT. +- High: Dapper TypeHandler interoperability depende de internal shape por + reflection. +- Medium: nao ha `global.json`, package lock ou Central Package Management. +- Medium: analyzer/generator release manifests precisam revisao para release. + +## Compatibility decisions + +- Manter `netstandard2.0` como TFM minimo dos pacotes publicos. +- Tratar `Dapper >= 2.1.79` como minimo atual e validar matriz antes de release. +- Tratar `Dommel >= 3.5.3` como minimo atual do pacote Dommel. +- Separar provider support de provider certification. +- Exigir API/binary compatibility formal antes de stable. +- Nao declarar Native AOT completo no estado atual. +- Usar RC antes de stable; recomendacao inicial `3.0.0-rc.1`, salvo prova + formal que permita `2.1.0-rc.1`. + +## Packages + +- `Dapper.FluentMap` +- `Dapper.FluentMap.Dommel` +- `Dapper.FluentMap.DependencyInjection` +- `Dapper.FluentMap.Analyzers` +- `Dapper.FluentMap.Generators` + +## Providers + +- Certified in automated tests: SQLite. +- Provider-independent coverage: `DataTableReader`/ADO.NET interfaces. +- Supported by Dommel builder registration but not certified in CI: SQL Server, + SQL CE, PostgreSQL, MySQL. + +## Known risks + +- Estado global permanece para `FluentMapper`, `SqlMapper.SetTypeMap` e + `DommelMapper`. +- `QueryMapped*` pode cair para fallback runtime sensivel a trimming/dynamic + code. +- Native AOT nao foi validado por execucao em ambiente com linker nativo. +- Pacotes novos ainda nao possuem historico publico no NuGet.org. +- CI atual pode estar fragil por versoes de GitHub Actions que precisam + confirmacao antes de release. + +## Arquivos importantes + +- `.sdd/etapa-12/01-release-readiness-audit.md` +- `.sdd/etapa-12/02-compatibility-spec.md` +- `.sdd/etapa-12/DECISIONS.md` +- `.sdd/etapa-12/STATUS.md` +- `README.md` +- `Dapper.FluentMap.sln` +- `.github/workflows/ci.yml` +- `src/Dapper.FluentMap/Dapper.FluentMap.csproj` +- `src/Dapper.FluentMap.Dommel/Dapper.FluentMap.Dommel.csproj` +- `src/Dapper.FluentMap.DependencyInjection/Dapper.FluentMap.DependencyInjection.csproj` +- `src/Dapper.FluentMap.Analyzers/Dapper.FluentMap.Analyzers.csproj` +- `src/Dapper.FluentMap.Generators/Dapper.FluentMap.Generators.csproj` + +## Validacao do Prompt 12.1 + +Executado em 2026-07-29: + +```bash +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 +dotnet list ./Dapper.FluentMap.sln package --vulnerable --include-transitive +``` + +Resultados: + +- Restore: sucesso. +- Build Release: sucesso, 0 warnings, 0 errors. +- Solution tests: sucesso, 453 testes aprovados, 0 falhas, 0 ignorados. +- Pack solution: sucesso; criou os 5 pacotes packable. +- Pack warnings: `NU5125` em core e Dommel por `licenseUrl` obsoleto; aviso de + README ausente em core e Dommel. +- Vulnerability audit: nenhum pacote vulneravel encontrado nas fontes atuais. + +## Ultimo prompt executado + +Ultimo prompt executado: 12.1 From c5364f3785a7d285c3e7b35543b0d58e64bde13b Mon Sep 17 00:00:00 2001 From: Rodrigo de Oliveira Date: Wed, 29 Jul 2026 10:46:45 -0300 Subject: [PATCH 37/49] ci(compat): validate supported Dapper versions --- .github/workflows/ci.yml | 80 ++++++++++- .sdd/etapa-12/03-compatibility-matrix.md | 132 ++++++++++++++++++ .sdd/etapa-12/DECISIONS.md | 37 +++++ .sdd/etapa-12/STATUS.md | 70 ++++++++-- Directory.Build.props | 7 + .../Dapper.FluentMap.Dommel.csproj | 2 +- src/Dapper.FluentMap/Dapper.FluentMap.csproj | 2 +- .../Dapper.FluentMap.AotSmoke.csproj | 2 +- 8 files changed, 309 insertions(+), 23 deletions(-) create mode 100644 .sdd/etapa-12/03-compatibility-matrix.md create mode 100644 Directory.Build.props diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9f63e28..3fd6b6a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -16,9 +16,16 @@ permissions: contents: read jobs: - build: - name: Build, test and pack + compatibility: + name: Compatibility (${{ matrix.dapper-lane }}, Dapper ${{ matrix.dapper-version }}) runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + include: + - dapper-lane: minimum-and-latest-stable + dapper-version: 2.1.79 + dotnet-version: 10.0.x steps: - name: Checkout @@ -27,21 +34,82 @@ jobs: - name: Setup .NET uses: actions/setup-dotnet@v6 with: - dotnet-version: 10.0.x + dotnet-version: ${{ matrix.dotnet-version }} dotnet-quality: ga - name: Show .NET info run: dotnet --info + - name: Restore + run: dotnet restore ./Dapper.FluentMap.sln -p:DapperPackageVersion=${{ matrix.dapper-version }} + + - name: Build Release + run: dotnet build ./Dapper.FluentMap.sln --configuration Release --no-restore -p:DapperPackageVersion=${{ matrix.dapper-version }} + + - name: Test core compatibility + run: dotnet test ./test/Dapper.FluentMap.Tests/Dapper.FluentMap.Tests.csproj --configuration Release --no-build + + - name: Test generated registration compatibility + run: dotnet test ./test/Dapper.FluentMap.GeneratedRegistration.Tests/Dapper.FluentMap.GeneratedRegistration.Tests.csproj --configuration Release --no-build + + - name: Test dependency injection compatibility + run: dotnet test ./test/Dapper.FluentMap.DependencyInjection.Tests/Dapper.FluentMap.DependencyInjection.Tests.csproj --configuration Release --no-build + + - name: Test Dommel compatibility + run: dotnet test ./test/Dapper.FluentMap.Dommel.Tests/Dapper.FluentMap.Dommel.Tests.csproj --configuration Release --no-build + + roslyn-components: + name: Analyzer and generator compatibility + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v7 + + - name: Setup .NET + uses: actions/setup-dotnet@v6 + with: + dotnet-version: 10.0.x + dotnet-quality: ga + + - name: Restore + run: dotnet restore ./Dapper.FluentMap.sln + + - name: Build analyzers + run: dotnet build ./src/Dapper.FluentMap.Analyzers/Dapper.FluentMap.Analyzers.csproj --configuration Release --no-restore + + - name: Build generators + run: dotnet build ./src/Dapper.FluentMap.Generators/Dapper.FluentMap.Generators.csproj --configuration Release --no-restore + + - name: Test analyzers + run: dotnet test ./test/Dapper.FluentMap.Analyzers.Tests/Dapper.FluentMap.Analyzers.Tests.csproj --configuration Release + + - name: Test generators + run: dotnet test ./test/Dapper.FluentMap.Generators.Tests/Dapper.FluentMap.Generators.Tests.csproj --configuration Release + + pack: + name: Pack + runs-on: ubuntu-latest + needs: + - compatibility + - roslyn-components + + steps: + - name: Checkout + uses: actions/checkout@v7 + + - name: Setup .NET + uses: actions/setup-dotnet@v6 + with: + dotnet-version: 10.0.x + dotnet-quality: ga + - name: Restore run: dotnet restore ./Dapper.FluentMap.sln - name: Build Release run: dotnet build ./Dapper.FluentMap.sln --configuration Release --no-restore - - name: Test Release - run: dotnet test ./Dapper.FluentMap.sln --configuration Release --no-build - - name: Pack Release run: dotnet pack ./Dapper.FluentMap.sln --configuration Release --no-build --output ./artifacts/packages diff --git a/.sdd/etapa-12/03-compatibility-matrix.md b/.sdd/etapa-12/03-compatibility-matrix.md new file mode 100644 index 0000000..2fef071 --- /dev/null +++ b/.sdd/etapa-12/03-compatibility-matrix.md @@ -0,0 +1,132 @@ +# Compatibility Matrix + +Matriz definida em 2026-07-29 para validar compatibilidade essencial sem +explodir combinacoes. O repositorio publica bibliotecas `netstandard2.0` e +executa testes em `net10.0`; portanto a matriz cruza a compilacao do TFM +publico com o runtime de testes moderno. + +## Supported Frameworks + +| TFM | Status | Notes | +| --- | ------ | ----- | +| `netstandard2.0` | Supported | TFM dos pacotes publicos: core, Dommel, DependencyInjection, Analyzers e Generators. | +| `net10.0` | Test runtime | TFM dos projetos de teste, AOT smoke e benchmarks. Nao eleva o TFM minimo dos pacotes. | + +## Dapper + +| Dapper | TFM | Build | Tests | Status | +| ------ | --- | ----- | ----- | ------ | +| `2.1.79` | `netstandard2.0` build + `net10.0` tests | Required | Required | Minimum supported and latest stable in NuGet.org on 2026-07-29. | + +No Dapper preview atual foi selecionado. O feed NuGet.org so apresentou +pre-releases antigas da linha `1.x`, sem valor para a matriz de release atual. + +## Dommel + +| Dommel | Dapper | TFM | Build | Tests | Status | +| ------ | ------ | --- | ----- | ----- | ------ | +| `3.5.3` | `2.1.79` | `netstandard2.0` build + `net10.0` tests | Required | Required | Minimum supported and latest stable in NuGet.org on 2026-07-29. | + +Dommel permanece pacote opcional e bridge process-wide. A matriz do core nao +promete isolamento Dommel por runtime. + +## Analyzer And Generator Components + +| Component | TFM | Compiler References | Build | Tests | Status | +| --------- | --- | ------------------- | ----- | ----- | ------ | +| `Dapper.FluentMap.Analyzers` | `netstandard2.0` | `Microsoft.CodeAnalysis.CSharp` `5.6.0`, `Microsoft.CodeAnalysis.Analyzers` `5.6.0` | Required | Required | Roslyn component; not a runtime TFM lane. | +| `Dapper.FluentMap.Generators` | `netstandard2.0` | `Microsoft.CodeAnalysis.CSharp` `5.6.0`, `Microsoft.CodeAnalysis.Analyzers` `5.6.0` | Required | Required | Roslyn component; not a runtime TFM lane. | + +Analyzer/generator compatibility is validated separately from runtime Dapper +compatibility because Roslyn package references are compiler/load-context +inputs, not runtime framework support claims. + +## Test Coverage Requirements + +The essential Dapper matrix runs the runtime-facing projects that cover: + +| Category | Evidence | +| -------- | -------- | +| Type maps | `Dapper.FluentMap.Tests` mapping registry, composition and compatibility bridge tests. | +| Constructors | `ConstructorMappingTests` and immutable/value-object materialization tests. | +| Nested mappings | `NestedMaterializationSpikeTests`, `ValueObjectMaterializationTests` and configuration validation tests. | +| Generated/runtime materialization | `Dapper.FluentMap.GeneratedRegistration.Tests`, generated materializer tests and runtime fallback tests. | +| Profiles | Profile tests in core, DI and configuration isolation suites. | +| QueryMultiple | `AdvancedQueryHardeningTests` and mapped grid reader tests. | +| Streaming | `QueryMappedUnbuffered*` sync/async tests. | +| Converters | `PropertyConversionMetadataTests`, runtime conversion tests and TypeHandler interoperability tests. | +| Configuration isolation | `IsolatedRuntimeTests`, `ConfigurationIsolationHardeningTests` and DI provider tests. | + +Benchmarks are intentionally excluded from the compatibility matrix. + +## Dependency Range + +The declared Dapper dependency range is: + +```xml +[2.1.79,3.0.0) +``` + +Rationale: + +- `2.1.79` is both the minimum currently supported Dapper and the latest stable + available on NuGet.org on 2026-07-29. +- The exclusive `3.0.0` upper bound prevents unvalidated future major versions + from being selected by consumers. +- Future Dapper stable releases within the `2.x` line must be added to the CI + matrix before a release claims them as latest validated. + +The CI can override restore with `-p:DapperPackageVersion=` so each +matrix lane restores a specific Dapper version while the package default keeps +the supported NuGet range. + +## CI Policy + +The essential CI lanes are: + +| Job | Matrix | Scope | +| --- | ------ | ----- | +| `compatibility` | Dapper version lane | Restore, build and runtime tests for core, generated registration, DI and Dommel. | +| `roslyn-components` | None | Analyzer build/test and generator build/test. | +| `pack` | None | Pack once with the default dependency range after compatibility jobs pass. | + +When a newer stable Dapper appears, add a second `compatibility` matrix lane: + +```yaml +- dapper-lane: latest-stable + dapper-version: +``` + +Do not add OS, provider or preview dimensions to this job until there is a +specific compatibility question. Provider certification and Native AOT remain +separate release gates. + +## Compatibility Boundary + +Allowed Dapper surface: + +- `SqlMapper.SetTypeMap` +- `SqlMapper.GetTypeMap` +- `SqlMapper.ExecuteReader` +- `SqlMapper.ExecuteReaderAsync` +- `SqlMapper.HasTypeHandler` +- `SqlMapper.ITypeMap` +- `SqlMapper.IMemberMap` +- `SqlMapper.TypeHandler` +- `DefaultTypeMap` +- `CommandDefinition` + +Sensitive boundary: + +- `Dapper.FluentMap.Compatibility.DapperTypeHandlerAdapter` resolves + `SqlMapper.TypeHandlerCache.Parse(object)` by reflection. This is the + known highest-risk Dapper compatibility point and must stay covered by tests + and diagnostics. + +No new direct access to Dapper internals was introduced in this prompt. + +## Known Incompatibilities + +No incompatible Dapper or Dommel stable version was found during Prompt 12.2. +The only validated Dapper stable available in the current NuGet context is +`2.1.79`, so maximum validated Dapper is also `2.1.79`. diff --git a/.sdd/etapa-12/DECISIONS.md b/.sdd/etapa-12/DECISIONS.md index ecb854a..1343a1c 100644 --- a/.sdd/etapa-12/DECISIONS.md +++ b/.sdd/etapa-12/DECISIONS.md @@ -261,3 +261,40 @@ generator/provider universal, permanecem nao suportados. Known limitations e compatibility matrix viram artefatos obrigatorios da release. + +## ADR-13 - Runtime and Dapper compatibility matrix + +### Contexto + +Os pacotes publicos targetam `netstandard2.0`, enquanto a suite executavel roda +em `net10.0`. A dependencia direta em Dapper era declarada como versao exata no +`.csproj`, mas no pacote NuGet isso virava minimo aberto `>= 2.1.79`. O prompt +12.2 exigiu matriz pragmatica entre TFMs suportados e versoes Dapper +suportadas. + +### Decisao + +Declarar a faixa `Dapper [2.1.79,3.0.0)` e validar em CI a lane essencial de +Dapper por propriedade MSBuild sobregravavel `DapperPackageVersion`. Como +`2.1.79` e simultaneamente a minima suportada e a latest stable em NuGet.org em +2026-07-29, a matriz atual possui uma unica lane +`minimum-and-latest-stable`. + +Separar analyzer/generator em job proprio de CI, porque esses pacotes dependem +de Roslyn/compiler references e nao representam runtime TFM support. + +### Alternativas consideradas + +- Manter dependencia aberta sem upper bound: rejeitado porque permitiria Dapper + major futuro sem validacao. +- Fixar exatamente `2.1.79` no pacote: rejeitado porque bloquearia consumidores + sem evidencia de incompatibilidade dentro da major atual. +- Criar matriz combinatoria de OS/provider/preview: rejeitado ate haver pergunta + concreta de compatibilidade. + +### Consequencias + +O pacote fica mais conservador contra Dapper `3.x`. Quando surgir nova stable +de Dapper `2.x`, o workflow deve ganhar lane `latest-stable` antes da release +reivindicar essa versao como validada. Dommel continua validado apenas como +integracao opcional process-wide. diff --git a/.sdd/etapa-12/STATUS.md b/.sdd/etapa-12/STATUS.md index 54f662c..0db016b 100644 --- a/.sdd/etapa-12/STATUS.md +++ b/.sdd/etapa-12/STATUS.md @@ -33,22 +33,26 @@ SourceLink/reproducibilidade e CI de release. - Executada validacao inicial obrigatoria de restore/build/test/pack. - Executada auditoria de vulnerabilidades via NuGet. - Consultado historico NuGet dos package IDs relevantes. +- Criado `03-compatibility-matrix.md`. +- Adicionada matriz essencial de CI por versao Dapper. +- Separada validacao de analyzer/generator da matriz runtime. +- Declarada propriedade MSBuild `DapperPackageVersion` para restaurar versoes + especificas na matriz. +- Confirmado por `git ls-remote` que as tags atuais de GitHub Actions usadas no + workflow existem (`checkout@v7`, `setup-dotnet@v6`, `upload-artifact@v7`). ## Em andamento -- Nenhuma implementacao em andamento. Este prompt foi limitado a auditoria, - especificacao, decisoes e status. +- Nenhuma implementacao em andamento. ## Proximos passos -1. Criar compatibility matrix formal por pacote, TFM, Dapper, Dommel e provider. -2. Adicionar baseline e tooling de API/binary compatibility. -3. Endurecer metadata de pacote, README de pacote, repository metadata, +1. Adicionar baseline e tooling de API/binary compatibility. +2. Endurecer metadata de pacote, README de pacote, repository metadata, SourceLink, symbols e package validation. -4. Ajustar CI para matriz minima e validar pack/trimming/compatibility. -5. Documentar migration guide, support policy e provider certification. -6. Definir e validar release candidate antes de stable. -7. Fazer auditoria final de release blockers. +3. Documentar migration guide, support policy e provider certification. +4. Definir e validar release candidate antes de stable. +5. Fazer auditoria final de release blockers. ## Release blockers @@ -59,7 +63,7 @@ SourceLink/reproducibilidade e CI de release. - High: NuGet metadata ainda aponta para o repositorio upstream original. - High: nao ha SourceLink, repository metadata, symbols ou deterministic CI policy. -- High: CI nao valida matriz de provider/Dapper/SDK nem smokes trimming/AOT. +- High: CI ainda nao valida matriz de provider/SDK nem smokes trimming/AOT. - High: Dapper TypeHandler interoperability depende de internal shape por reflection. - Medium: nao ha `global.json`, package lock ou Central Package Management. @@ -68,7 +72,8 @@ SourceLink/reproducibilidade e CI de release. ## Compatibility decisions - Manter `netstandard2.0` como TFM minimo dos pacotes publicos. -- Tratar `Dapper >= 2.1.79` como minimo atual e validar matriz antes de release. +- Tratar `Dapper [2.1.79,3.0.0)` como faixa suportada atual, com `2.1.79` + validado como minimo e latest stable no Prompt 12.2. - Tratar `Dommel >= 3.5.3` como minimo atual do pacote Dommel. - Separar provider support de provider certification. - Exigir API/binary compatibility formal antes de stable. @@ -99,13 +104,12 @@ SourceLink/reproducibilidade e CI de release. code. - Native AOT nao foi validado por execucao em ambiente com linker nativo. - Pacotes novos ainda nao possuem historico publico no NuGet.org. -- CI atual pode estar fragil por versoes de GitHub Actions que precisam - confirmacao antes de release. ## Arquivos importantes - `.sdd/etapa-12/01-release-readiness-audit.md` - `.sdd/etapa-12/02-compatibility-spec.md` +- `.sdd/etapa-12/03-compatibility-matrix.md` - `.sdd/etapa-12/DECISIONS.md` - `.sdd/etapa-12/STATUS.md` - `README.md` @@ -139,6 +143,44 @@ Resultados: README ausente em core e Dommel. - Vulnerability audit: nenhum pacote vulneravel encontrado nas fontes atuais. +## Validacao do Prompt 12.2 + +Executada localmente em 2026-07-29: + +```bash +dotnet restore ./Dapper.FluentMap.sln -p:DapperPackageVersion=2.1.79 +dotnet build ./Dapper.FluentMap.sln --configuration Release --no-restore -p:DapperPackageVersion=2.1.79 +dotnet test ./test/Dapper.FluentMap.Tests/Dapper.FluentMap.Tests.csproj --configuration Release --no-build +dotnet test ./test/Dapper.FluentMap.GeneratedRegistration.Tests/Dapper.FluentMap.GeneratedRegistration.Tests.csproj --configuration Release --no-build +dotnet test ./test/Dapper.FluentMap.DependencyInjection.Tests/Dapper.FluentMap.DependencyInjection.Tests.csproj --configuration Release --no-build +dotnet test ./test/Dapper.FluentMap.Dommel.Tests/Dapper.FluentMap.Dommel.Tests.csproj --configuration Release --no-build +dotnet test ./test/Dapper.FluentMap.Analyzers.Tests/Dapper.FluentMap.Analyzers.Tests.csproj --configuration Release +dotnet test ./test/Dapper.FluentMap.Generators.Tests/Dapper.FluentMap.Generators.Tests.csproj --configuration Release +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 +``` + +Resultados: + +- Restore da matriz Dapper `2.1.79`: sucesso. +- Build Release da matriz Dapper `2.1.79`: sucesso, 0 warnings, 0 errors. +- Runtime compatibility tests: + - Core: 370 aprovados. + - Generated registration: 6 aprovados. + - DependencyInjection: 9 aprovados. + - Dommel: 23 aprovados. +- Analyzer tests: 19 aprovados. +- Generator tests: 26 aprovados. +- Restore/build/test padrao da solution com range default: sucesso; 453 testes + aprovados. +- Pack solution: sucesso; warnings conhecidos `NU5125` em core/Dommel por + `licenseUrl` obsoleto e aviso de README ausente nesses pacotes. +- Inspecao do nuspec: + - `Dapper.FluentMap`: `Dapper` `[2.1.79, 3.0.0)`. + - `Dapper.FluentMap.Dommel`: `Dapper` `[2.1.79, 3.0.0)`, `Dommel` `3.5.3`. + ## Ultimo prompt executado -Ultimo prompt executado: 12.1 +Ultimo prompt executado: 12.2 diff --git a/Directory.Build.props b/Directory.Build.props new file mode 100644 index 0000000..809224f --- /dev/null +++ b/Directory.Build.props @@ -0,0 +1,7 @@ + + + 2.1.79 + 2.1.79 + [$(DapperMinimumSupportedVersion),3.0.0) + + diff --git a/src/Dapper.FluentMap.Dommel/Dapper.FluentMap.Dommel.csproj b/src/Dapper.FluentMap.Dommel/Dapper.FluentMap.Dommel.csproj index 267526c..1bb5732 100644 --- a/src/Dapper.FluentMap.Dommel/Dapper.FluentMap.Dommel.csproj +++ b/src/Dapper.FluentMap.Dommel/Dapper.FluentMap.Dommel.csproj @@ -12,7 +12,7 @@ - + diff --git a/src/Dapper.FluentMap/Dapper.FluentMap.csproj b/src/Dapper.FluentMap/Dapper.FluentMap.csproj index b41720d..375df4c 100644 --- a/src/Dapper.FluentMap/Dapper.FluentMap.csproj +++ b/src/Dapper.FluentMap/Dapper.FluentMap.csproj @@ -12,7 +12,7 @@ https://github.com/henkmollema/Dapper-FluentMap/blob/master/LICENSE - + diff --git a/test/Dapper.FluentMap.AotSmoke/Dapper.FluentMap.AotSmoke.csproj b/test/Dapper.FluentMap.AotSmoke/Dapper.FluentMap.AotSmoke.csproj index 167ded4..5e0a867 100644 --- a/test/Dapper.FluentMap.AotSmoke/Dapper.FluentMap.AotSmoke.csproj +++ b/test/Dapper.FluentMap.AotSmoke/Dapper.FluentMap.AotSmoke.csproj @@ -16,7 +16,7 @@ ..\..\src\Dapper.FluentMap.DependencyInjection\bin\$(Configuration)\netstandard2.0\Dapper.FluentMap.DependencyInjection.dll - + From 61db3b3305c96eb162454e2387c1d585cf7b9a0f Mon Sep 17 00:00:00 2001 From: Rodrigo de Oliveira Date: Wed, 29 Jul 2026 10:59:42 -0300 Subject: [PATCH 38/49] test(providers): validate database compatibility --- .github/workflows/ci.yml | 3 + .sdd/etapa-12/04-provider-matrix.md | 100 ++ .sdd/etapa-12/DECISIONS.md | 39 + .sdd/etapa-12/STATUS.md | 62 +- Dapper.FluentMap.sln | 124 +++ .../AssemblyInfo.cs | 3 + ...uentMap.ProviderCompatibility.Tests.csproj | 20 + .../ProviderCompatibilityTests.cs | 890 ++++++++++++++++++ 8 files changed, 1237 insertions(+), 4 deletions(-) create mode 100644 .sdd/etapa-12/04-provider-matrix.md create mode 100644 test/Dapper.FluentMap.ProviderCompatibility.Tests/AssemblyInfo.cs create mode 100644 test/Dapper.FluentMap.ProviderCompatibility.Tests/Dapper.FluentMap.ProviderCompatibility.Tests.csproj create mode 100644 test/Dapper.FluentMap.ProviderCompatibility.Tests/ProviderCompatibilityTests.cs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3fd6b6a..6876a6f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -58,6 +58,9 @@ jobs: - name: Test Dommel compatibility run: dotnet test ./test/Dapper.FluentMap.Dommel.Tests/Dapper.FluentMap.Dommel.Tests.csproj --configuration Release --no-build + - name: Test provider compatibility + run: dotnet test ./test/Dapper.FluentMap.ProviderCompatibility.Tests/Dapper.FluentMap.ProviderCompatibility.Tests.csproj --configuration Release --no-build + roslyn-components: name: Analyzer and generator compatibility runs-on: ubuntu-latest diff --git a/.sdd/etapa-12/04-provider-matrix.md b/.sdd/etapa-12/04-provider-matrix.md new file mode 100644 index 0000000..fd42acb --- /dev/null +++ b/.sdd/etapa-12/04-provider-matrix.md @@ -0,0 +1,100 @@ +# Provider Compatibility Matrix + +Documento criado em 2026-07-29 para o Prompt 12.3. + +## Provider-Agnostic Design vs Provider Validated/Certified + +`Provider-agnostic design` significa que o core usa contratos comuns de +ADO.NET, Dapper e `DbDataReader`, sem codificar comportamento especifico de +banco no materializador. Esse desenho e necessario para portabilidade, mas nao +prova que um provider concreto preserva os mesmos tipos CLR, lifetime de reader, +multiple result sets, cancellation, identity retrieval ou SQL gerado pelo +Dommel. + +`Provider validated/certified` significa que existe teste de integracao real +contra aquele provider e banco, executado localmente ou em CI, cobrindo os +cenarios declarados. Mocks de ADO.NET, `DataTableReader` e apenas SQL builders +registrados contam como cobertura provider-independent ou support-by-design, nao +como certificacao de provider. + +## Status Vocabulary + +| Status | Meaning | +| ------ | ------- | +| `Validated` | Teste real automatizado passou no ambiente registrado. | +| `Partial` | Ha harness, builder ou cobertura parcial, mas falta execucao real completa ou CI. | +| `Not validated` | Nao ha evidencia de teste real executado para este provider neste prompt. | +| `Unsupported upstream` | Limitacao do provider/banco ou dependencia upstream impede tratar como bug do FluentMap. | + +## Matrix + +| Provider | Basic Read | Nested | Constructor | QueryMultiple | Streaming | Persistence | Status | +| -------- | ---------- | ------ | ----------- | ------------- | --------- | ----------- | ------ | +| SQLite (`Microsoft.Data.Sqlite`) | `Validated`: column rename, null, Guid, DateTime, decimal | `Validated`: nested object and value object | `Validated`: immutable constructor | `Validated`: sequential result sets through `QueryMultipleMapped` | `Validated`: sync early termination, async cancellation, reader release | `Validated`: Dommel identity, non-identity key, computed, database default, read-only | `Validated` | +| SQL Server (`Microsoft.Data.SqlClient`) | Conditional harness via `DFM_SQLSERVER_CONNECTION_STRING`; not executed locally/CI in this prompt | Conditional harness | Conditional harness | Conditional harness; subject to provider multiple-result behavior | Conditional harness | Conditional Dommel harness; identity/default/computed SQL Server DDL defined | `Not validated` | +| PostgreSQL (`Npgsql`) | Conditional harness via `DFM_POSTGRESQL_CONNECTION_STRING`; not executed locally/CI in this prompt | Conditional harness | Conditional harness | Conditional harness; subject to Npgsql multiple-result behavior | Conditional harness | Conditional Dommel harness; identity/default/generated column PostgreSQL DDL defined | `Not validated` | +| MySQL/MariaDB | No harness added; Dommel builder remains registered support-by-design only | No harness | No harness | Not evaluated | Not evaluated | Not evaluated | `Not validated` | +| SQL Server CE | Dommel builder remains registered for compatibility, but no modern provider lane exists | Not evaluated | Not evaluated | Not evaluated | Not evaluated | Not evaluated | `Unsupported upstream` | + +## Test Strategy + +Foi criado `test/Dapper.FluentMap.ProviderCompatibility.Tests` para concentrar +testes reais de provider sem poluir a suite core. O projeto: + +- roda SQLite sempre, usando `Microsoft.Data.Sqlite` in-memory; +- inclui SQL Server e PostgreSQL como lanes condicionais por connection string; +- usa providers reais de ADO.NET, nao mocks; +- cobre leitura basica, materializacao avancada, `QueryMultipleMapped`, + streaming sync/async e persistencia Dommel; +- desabilita paralelismo no assembly porque `FluentMapper`, `SqlMapper` e + `DommelMapper` usam estado global process-wide. + +Connection strings opcionais: + +```text +DFM_SQLSERVER_CONNECTION_STRING +DFM_POSTGRESQL_CONNECTION_STRING +``` + +Sem essas variaveis, as lanes SQL Server/PostgreSQL sao marcadas como skipped +com diagnostico explicito. Isso nao e certificacao; apenas preserva um harness +executavel quando a infraestrutura real existir. + +## CI + +A CI passa a executar o projeto de provider compatibility no job +`compatibility`. No estado atual, isso certifica SQLite na lane rapida e registra +SQL Server/PostgreSQL como nao executados quando as connection strings nao +existem. + +Nao foram adicionados Testcontainers nem service containers neste prompt porque: + +- o repositorio nao possuia infraestrutura existente para containers de banco; +- SQL Server container aumenta tempo e custo do build principal; +- PostgreSQL local existia na maquina, mas pertencia a outro stack em execucao e + nao foi usado como fonte de verdade; +- MySQL/MariaDB nao tinha dependencia, imagem ou demanda suficiente para virar + obrigatorio agora. + +Proximo passo recomendado: criar um job separado `provider-infrastructure` com +service containers para SQL Server e PostgreSQL, timeouts proprios e artefatos +de log, antes de mudar esses providers para `Validated`. + +## Provider Differences Documented + +- Identity retrieval pertence ao banco/provider e ao SQL builder do Dommel; o + core nao normaliza esse comportamento. +- Boolean representation nao recebeu claim provider-certified neste prompt; os + testes novos focam em rename/null/Guid/DateTime/decimal e materializacao + avancada. +- `DateTime` pode variar em precision, timezone e kind por provider. Os testes + usam valores sem fracao sub-millisecond e sem timezone para evitar prometer + semantica acima do provider. +- Case sensitivity e quoted identifiers nao foram normalizados. Os nomes de + tabelas/colunas dos testes evitam quoting para validar o caminho comum. +- Multiple result sets sao tratados como provider capability. Se um provider + falhar por limitacao upstream, deve ser registrado como provider limitation, + nao como bug automatico do FluentMap. +- Async streaming propaga cancellation aos pontos ADO.NET que aceitam token, mas + providers podem implementar async internamente de modo sincrono. O FluentMap + nao promete cancelamento mais forte que o contrato do provider. diff --git a/.sdd/etapa-12/DECISIONS.md b/.sdd/etapa-12/DECISIONS.md index 1343a1c..ad3e496 100644 --- a/.sdd/etapa-12/DECISIONS.md +++ b/.sdd/etapa-12/DECISIONS.md @@ -298,3 +298,42 @@ O pacote fica mais conservador contra Dapper `3.x`. Quando surgir nova stable de Dapper `2.x`, o workflow deve ganhar lane `latest-stable` antes da release reivindicar essa versao como validada. Dommel continua validado apenas como integracao opcional process-wide. + +## ADR-14 - Provider certification boundary + +### Contexto + +O core e desenhado sobre ADO.NET/Dapper, mas isso nao certifica automaticamente +SQL Server, PostgreSQL, SQLite, MySQL/MariaDB ou SQL CE. O Prompt 12.3 exigiu +separar design provider-agnostic de validacao real e criar uma matriz de +provider. + +### Decisao + +Criar um projeto dedicado `Dapper.FluentMap.ProviderCompatibility.Tests`. +SQLite e certificado automaticamente na lane rapida. SQL Server e PostgreSQL +ganham harness condicional por `DFM_SQLSERVER_CONNECTION_STRING` e +`DFM_POSTGRESQL_CONNECTION_STRING`, mas permanecem `Not validated` ate serem +executados contra servicos reais em ambiente controlado ou CI. + +MySQL/MariaDB nao vira obrigatorio neste prompt porque nao havia dependencia, +imagem, service container ou cobertura previa suficiente. SQL CE e tratado como +limitacao upstream/legado, apesar do builder Dommel continuar registrado. + +### Alternativas consideradas + +- Declarar todos os builders Dommel como certificados: rejeitado por ausencia + de teste real. +- Adicionar Testcontainers imediatamente: rejeitado porque o repositorio nao + tinha infraestrutura existente e a instrucao pediu nao adicionar + automaticamente se houver caminho mais simples. +- Rodar contra containers locais de outros projetos: rejeitado porque isso + acoplaria a validacao a estado externo nao reprodutivel deste repositorio. + +### Consequencias + +A CI valida SQLite como provider real sem deixar o build principal muito lento. +SQL Server/PostgreSQL ficam prontos para validacao assim que connection strings +ou service containers dedicados forem configurados. A documentacao deve usar +`Validated`, `Partial`, `Not validated` e `Unsupported upstream` de forma +explicita. diff --git a/.sdd/etapa-12/STATUS.md b/.sdd/etapa-12/STATUS.md index 0db016b..dcdfd28 100644 --- a/.sdd/etapa-12/STATUS.md +++ b/.sdd/etapa-12/STATUS.md @@ -40,6 +40,16 @@ SourceLink/reproducibilidade e CI de release. especificas na matriz. - Confirmado por `git ls-remote` que as tags atuais de GitHub Actions usadas no workflow existem (`checkout@v7`, `setup-dotnet@v6`, `upload-artifact@v7`). +- Criado `04-provider-matrix.md`. +- Criado projeto `test/Dapper.FluentMap.ProviderCompatibility.Tests`. +- Adicionados testes reais de provider para SQLite cobrindo leitura basica, + materializacao avancada, `QueryMultipleMapped`, streaming sync/async e + persistencia Dommel. +- Adicionado harness condicional para SQL Server via + `DFM_SQLSERVER_CONNECTION_STRING`. +- Adicionado harness condicional para PostgreSQL via + `DFM_POSTGRESQL_CONNECTION_STRING`. +- Adicionada etapa de provider compatibility ao job `compatibility` da CI. ## Em andamento @@ -63,7 +73,8 @@ SourceLink/reproducibilidade e CI de release. - High: NuGet metadata ainda aponta para o repositorio upstream original. - High: nao ha SourceLink, repository metadata, symbols ou deterministic CI policy. -- High: CI ainda nao valida matriz de provider/SDK nem smokes trimming/AOT. +- High: CI ainda nao valida SQL Server/PostgreSQL com servicos reais nem smokes + trimming/AOT. - High: Dapper TypeHandler interoperability depende de internal shape por reflection. - Medium: nao ha `global.json`, package lock ou Central Package Management. @@ -93,8 +104,11 @@ SourceLink/reproducibilidade e CI de release. - Certified in automated tests: SQLite. - Provider-independent coverage: `DataTableReader`/ADO.NET interfaces. -- Supported by Dommel builder registration but not certified in CI: SQL Server, - SQL CE, PostgreSQL, MySQL. +- Conditional harness but not certified in CI: SQL Server, PostgreSQL. +- Supported by Dommel builder registration but not certified in CI: + SQL Server, SQL CE, PostgreSQL, MySQL. +- MySQL/MariaDB: not validated; no mandatory lane added in Prompt 12.3. +- SQL CE: unsupported upstream/legacy validation lane. ## Known risks @@ -183,4 +197,44 @@ Resultados: ## Ultimo prompt executado -Ultimo prompt executado: 12.2 +Ultimo prompt executado: 12.3 + +## Validacao do Prompt 12.3 + +Executada localmente em 2026-07-29: + +```bash +dotnet test ./test/Dapper.FluentMap.ProviderCompatibility.Tests/Dapper.FluentMap.ProviderCompatibility.Tests.csproj --configuration Release +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 +dotnet list ./Dapper.FluentMap.sln package --vulnerable --include-transitive +``` + +Resultados: + +- Build/test do projeto provider compatibility: sucesso. +- SQLite: 7 cenarios aprovados. +- SQL Server: 7 cenarios skipped por ausencia de + `DFM_SQLSERVER_CONNECTION_STRING`. +- PostgreSQL: 7 cenarios skipped por ausencia de + `DFM_POSTGRESQL_CONNECTION_STRING`. +- Restore solution: sucesso. +- Build Release solution: sucesso, 0 warnings, 0 errors. +- Test solution: sucesso; 460 aprovados, 14 ignored/skipped, 0 falhas. +- Pack solution: sucesso; pacotes packable gerados. +- Pack warnings conhecidos: `NU5125` e README ausente em core/Dommel. +- Vulnerability audit: nenhum pacote vulneravel encontrado nas fontes atuais, + incluindo as novas dependencias de teste `Microsoft.Data.SqlClient` e + `Npgsql`. + +Status por provider: + +| Provider | Status | Observacao | +| --- | --- | --- | +| SQLite | `Validated` | Testes reais automatizados passaram localmente. | +| SQL Server | `Not validated` | Harness condicional existe, mas nao foi executado contra servico real. | +| PostgreSQL | `Not validated` | Harness condicional existe, mas nao foi executado contra servico real. | +| MySQL/MariaDB | `Not validated` | Nao ha harness obrigatorio neste prompt. | +| SQL Server CE | `Unsupported upstream` | Builder legado permanece, sem lane moderna de validacao. | diff --git a/Dapper.FluentMap.sln b/Dapper.FluentMap.sln index bd35504..04bdb42 100644 --- a/Dapper.FluentMap.sln +++ b/Dapper.FluentMap.sln @@ -1,3 +1,4 @@ + Microsoft Visual Studio Solution File, Format Version 12.00 # Visual Studio Version 16 VisualStudioVersion = 16.0.29613.14 @@ -39,64 +40,186 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Dapper.FluentMap.Dependency EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Dapper.FluentMap.DependencyInjection.Tests", "test\Dapper.FluentMap.DependencyInjection.Tests\Dapper.FluentMap.DependencyInjection.Tests.csproj", "{D90BE707-E2E4-4085-A09D-11BE81B379A5}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Dapper.FluentMap.ProviderCompatibility.Tests", "test\Dapper.FluentMap.ProviderCompatibility.Tests\Dapper.FluentMap.ProviderCompatibility.Tests.csproj", "{38D36222-9B32-446B-9B40-4C0CB6958EA8}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU + Debug|x64 = Debug|x64 + Debug|x86 = Debug|x86 Release|Any CPU = Release|Any CPU + Release|x64 = Release|x64 + Release|x86 = Release|x86 EndGlobalSection GlobalSection(ProjectConfigurationPlatforms) = postSolution {457E0B9B-F6A4-42C6-BFAE-6F8C71D1F435}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {457E0B9B-F6A4-42C6-BFAE-6F8C71D1F435}.Debug|Any CPU.Build.0 = Debug|Any CPU + {457E0B9B-F6A4-42C6-BFAE-6F8C71D1F435}.Debug|x64.ActiveCfg = Debug|Any CPU + {457E0B9B-F6A4-42C6-BFAE-6F8C71D1F435}.Debug|x64.Build.0 = Debug|Any CPU + {457E0B9B-F6A4-42C6-BFAE-6F8C71D1F435}.Debug|x86.ActiveCfg = Debug|Any CPU + {457E0B9B-F6A4-42C6-BFAE-6F8C71D1F435}.Debug|x86.Build.0 = Debug|Any CPU {457E0B9B-F6A4-42C6-BFAE-6F8C71D1F435}.Release|Any CPU.ActiveCfg = Release|Any CPU {457E0B9B-F6A4-42C6-BFAE-6F8C71D1F435}.Release|Any CPU.Build.0 = Release|Any CPU + {457E0B9B-F6A4-42C6-BFAE-6F8C71D1F435}.Release|x64.ActiveCfg = Release|Any CPU + {457E0B9B-F6A4-42C6-BFAE-6F8C71D1F435}.Release|x64.Build.0 = Release|Any CPU + {457E0B9B-F6A4-42C6-BFAE-6F8C71D1F435}.Release|x86.ActiveCfg = Release|Any CPU + {457E0B9B-F6A4-42C6-BFAE-6F8C71D1F435}.Release|x86.Build.0 = Release|Any CPU {8901F2FD-F98B-484B-A20A-7844A39C7458}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {8901F2FD-F98B-484B-A20A-7844A39C7458}.Debug|Any CPU.Build.0 = Debug|Any CPU + {8901F2FD-F98B-484B-A20A-7844A39C7458}.Debug|x64.ActiveCfg = Debug|Any CPU + {8901F2FD-F98B-484B-A20A-7844A39C7458}.Debug|x64.Build.0 = Debug|Any CPU + {8901F2FD-F98B-484B-A20A-7844A39C7458}.Debug|x86.ActiveCfg = Debug|Any CPU + {8901F2FD-F98B-484B-A20A-7844A39C7458}.Debug|x86.Build.0 = Debug|Any CPU {8901F2FD-F98B-484B-A20A-7844A39C7458}.Release|Any CPU.ActiveCfg = Release|Any CPU {8901F2FD-F98B-484B-A20A-7844A39C7458}.Release|Any CPU.Build.0 = Release|Any CPU + {8901F2FD-F98B-484B-A20A-7844A39C7458}.Release|x64.ActiveCfg = Release|Any CPU + {8901F2FD-F98B-484B-A20A-7844A39C7458}.Release|x64.Build.0 = Release|Any CPU + {8901F2FD-F98B-484B-A20A-7844A39C7458}.Release|x86.ActiveCfg = Release|Any CPU + {8901F2FD-F98B-484B-A20A-7844A39C7458}.Release|x86.Build.0 = Release|Any CPU {E60B79F6-FE71-44E0-BE88-BFA269378EDB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {E60B79F6-FE71-44E0-BE88-BFA269378EDB}.Debug|Any CPU.Build.0 = Debug|Any CPU + {E60B79F6-FE71-44E0-BE88-BFA269378EDB}.Debug|x64.ActiveCfg = Debug|Any CPU + {E60B79F6-FE71-44E0-BE88-BFA269378EDB}.Debug|x64.Build.0 = Debug|Any CPU + {E60B79F6-FE71-44E0-BE88-BFA269378EDB}.Debug|x86.ActiveCfg = Debug|Any CPU + {E60B79F6-FE71-44E0-BE88-BFA269378EDB}.Debug|x86.Build.0 = Debug|Any CPU {E60B79F6-FE71-44E0-BE88-BFA269378EDB}.Release|Any CPU.ActiveCfg = Release|Any CPU {E60B79F6-FE71-44E0-BE88-BFA269378EDB}.Release|Any CPU.Build.0 = Release|Any CPU + {E60B79F6-FE71-44E0-BE88-BFA269378EDB}.Release|x64.ActiveCfg = Release|Any CPU + {E60B79F6-FE71-44E0-BE88-BFA269378EDB}.Release|x64.Build.0 = Release|Any CPU + {E60B79F6-FE71-44E0-BE88-BFA269378EDB}.Release|x86.ActiveCfg = Release|Any CPU + {E60B79F6-FE71-44E0-BE88-BFA269378EDB}.Release|x86.Build.0 = Release|Any CPU {DFB62D87-9A74-40DF-A930-8F61A53E0F1B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {DFB62D87-9A74-40DF-A930-8F61A53E0F1B}.Debug|Any CPU.Build.0 = Debug|Any CPU + {DFB62D87-9A74-40DF-A930-8F61A53E0F1B}.Debug|x64.ActiveCfg = Debug|Any CPU + {DFB62D87-9A74-40DF-A930-8F61A53E0F1B}.Debug|x64.Build.0 = Debug|Any CPU + {DFB62D87-9A74-40DF-A930-8F61A53E0F1B}.Debug|x86.ActiveCfg = Debug|Any CPU + {DFB62D87-9A74-40DF-A930-8F61A53E0F1B}.Debug|x86.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 + {DFB62D87-9A74-40DF-A930-8F61A53E0F1B}.Release|x64.ActiveCfg = Release|Any CPU + {DFB62D87-9A74-40DF-A930-8F61A53E0F1B}.Release|x64.Build.0 = Release|Any CPU + {DFB62D87-9A74-40DF-A930-8F61A53E0F1B}.Release|x86.ActiveCfg = Release|Any CPU + {DFB62D87-9A74-40DF-A930-8F61A53E0F1B}.Release|x86.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}.Debug|x64.ActiveCfg = Debug|Any CPU + {424B90AD-406E-4CC1-B0F4-917F47A06E4D}.Debug|x64.Build.0 = Debug|Any CPU + {424B90AD-406E-4CC1-B0F4-917F47A06E4D}.Debug|x86.ActiveCfg = Debug|Any CPU + {424B90AD-406E-4CC1-B0F4-917F47A06E4D}.Debug|x86.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 + {424B90AD-406E-4CC1-B0F4-917F47A06E4D}.Release|x64.ActiveCfg = Release|Any CPU + {424B90AD-406E-4CC1-B0F4-917F47A06E4D}.Release|x64.Build.0 = Release|Any CPU + {424B90AD-406E-4CC1-B0F4-917F47A06E4D}.Release|x86.ActiveCfg = Release|Any CPU + {424B90AD-406E-4CC1-B0F4-917F47A06E4D}.Release|x86.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}.Debug|x64.ActiveCfg = Debug|Any CPU + {F5059D11-D45B-4793-B6E0-7758F57AC0E1}.Debug|x64.Build.0 = Debug|Any CPU + {F5059D11-D45B-4793-B6E0-7758F57AC0E1}.Debug|x86.ActiveCfg = Debug|Any CPU + {F5059D11-D45B-4793-B6E0-7758F57AC0E1}.Debug|x86.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 + {F5059D11-D45B-4793-B6E0-7758F57AC0E1}.Release|x64.ActiveCfg = Release|Any CPU + {F5059D11-D45B-4793-B6E0-7758F57AC0E1}.Release|x64.Build.0 = Release|Any CPU + {F5059D11-D45B-4793-B6E0-7758F57AC0E1}.Release|x86.ActiveCfg = Release|Any CPU + {F5059D11-D45B-4793-B6E0-7758F57AC0E1}.Release|x86.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}.Debug|x64.ActiveCfg = Debug|Any CPU + {2E23213D-A547-4FF6-BB58-8793860C18FE}.Debug|x64.Build.0 = Debug|Any CPU + {2E23213D-A547-4FF6-BB58-8793860C18FE}.Debug|x86.ActiveCfg = Debug|Any CPU + {2E23213D-A547-4FF6-BB58-8793860C18FE}.Debug|x86.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 + {2E23213D-A547-4FF6-BB58-8793860C18FE}.Release|x64.ActiveCfg = Release|Any CPU + {2E23213D-A547-4FF6-BB58-8793860C18FE}.Release|x64.Build.0 = Release|Any CPU + {2E23213D-A547-4FF6-BB58-8793860C18FE}.Release|x86.ActiveCfg = Release|Any CPU + {2E23213D-A547-4FF6-BB58-8793860C18FE}.Release|x86.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}.Debug|x64.ActiveCfg = Debug|Any CPU + {25768DB1-489F-4544-BDD4-8B0D0E88C6E5}.Debug|x64.Build.0 = Debug|Any CPU + {25768DB1-489F-4544-BDD4-8B0D0E88C6E5}.Debug|x86.ActiveCfg = Debug|Any CPU + {25768DB1-489F-4544-BDD4-8B0D0E88C6E5}.Debug|x86.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 + {25768DB1-489F-4544-BDD4-8B0D0E88C6E5}.Release|x64.ActiveCfg = Release|Any CPU + {25768DB1-489F-4544-BDD4-8B0D0E88C6E5}.Release|x64.Build.0 = Release|Any CPU + {25768DB1-489F-4544-BDD4-8B0D0E88C6E5}.Release|x86.ActiveCfg = Release|Any CPU + {25768DB1-489F-4544-BDD4-8B0D0E88C6E5}.Release|x86.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}.Debug|x64.ActiveCfg = Debug|Any CPU + {BA72BEA0-BB6E-41EE-ABFE-215FF0A1E9BB}.Debug|x64.Build.0 = Debug|Any CPU + {BA72BEA0-BB6E-41EE-ABFE-215FF0A1E9BB}.Debug|x86.ActiveCfg = Debug|Any CPU + {BA72BEA0-BB6E-41EE-ABFE-215FF0A1E9BB}.Debug|x86.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 + {BA72BEA0-BB6E-41EE-ABFE-215FF0A1E9BB}.Release|x64.ActiveCfg = Release|Any CPU + {BA72BEA0-BB6E-41EE-ABFE-215FF0A1E9BB}.Release|x64.Build.0 = Release|Any CPU + {BA72BEA0-BB6E-41EE-ABFE-215FF0A1E9BB}.Release|x86.ActiveCfg = Release|Any CPU + {BA72BEA0-BB6E-41EE-ABFE-215FF0A1E9BB}.Release|x86.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}.Debug|x64.ActiveCfg = Debug|Any CPU + {87E09F49-F805-44EB-BA59-87C93C68497D}.Debug|x64.Build.0 = Debug|Any CPU + {87E09F49-F805-44EB-BA59-87C93C68497D}.Debug|x86.ActiveCfg = Debug|Any CPU + {87E09F49-F805-44EB-BA59-87C93C68497D}.Debug|x86.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 + {87E09F49-F805-44EB-BA59-87C93C68497D}.Release|x64.ActiveCfg = Release|Any CPU + {87E09F49-F805-44EB-BA59-87C93C68497D}.Release|x64.Build.0 = Release|Any CPU + {87E09F49-F805-44EB-BA59-87C93C68497D}.Release|x86.ActiveCfg = Release|Any CPU + {87E09F49-F805-44EB-BA59-87C93C68497D}.Release|x86.Build.0 = Release|Any CPU {B09CFDAC-19CB-48F2-B7F7-03A47430C707}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {B09CFDAC-19CB-48F2-B7F7-03A47430C707}.Debug|Any CPU.Build.0 = Debug|Any CPU + {B09CFDAC-19CB-48F2-B7F7-03A47430C707}.Debug|x64.ActiveCfg = Debug|Any CPU + {B09CFDAC-19CB-48F2-B7F7-03A47430C707}.Debug|x64.Build.0 = Debug|Any CPU + {B09CFDAC-19CB-48F2-B7F7-03A47430C707}.Debug|x86.ActiveCfg = Debug|Any CPU + {B09CFDAC-19CB-48F2-B7F7-03A47430C707}.Debug|x86.Build.0 = Debug|Any CPU {B09CFDAC-19CB-48F2-B7F7-03A47430C707}.Release|Any CPU.ActiveCfg = Release|Any CPU {B09CFDAC-19CB-48F2-B7F7-03A47430C707}.Release|Any CPU.Build.0 = Release|Any CPU + {B09CFDAC-19CB-48F2-B7F7-03A47430C707}.Release|x64.ActiveCfg = Release|Any CPU + {B09CFDAC-19CB-48F2-B7F7-03A47430C707}.Release|x64.Build.0 = Release|Any CPU + {B09CFDAC-19CB-48F2-B7F7-03A47430C707}.Release|x86.ActiveCfg = Release|Any CPU + {B09CFDAC-19CB-48F2-B7F7-03A47430C707}.Release|x86.Build.0 = Release|Any CPU {23695A6A-DC6C-44F0-99DF-8570AC9118F2}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {23695A6A-DC6C-44F0-99DF-8570AC9118F2}.Debug|Any CPU.Build.0 = Debug|Any CPU + {23695A6A-DC6C-44F0-99DF-8570AC9118F2}.Debug|x64.ActiveCfg = Debug|Any CPU + {23695A6A-DC6C-44F0-99DF-8570AC9118F2}.Debug|x64.Build.0 = Debug|Any CPU + {23695A6A-DC6C-44F0-99DF-8570AC9118F2}.Debug|x86.ActiveCfg = Debug|Any CPU + {23695A6A-DC6C-44F0-99DF-8570AC9118F2}.Debug|x86.Build.0 = Debug|Any CPU {23695A6A-DC6C-44F0-99DF-8570AC9118F2}.Release|Any CPU.ActiveCfg = Release|Any CPU {23695A6A-DC6C-44F0-99DF-8570AC9118F2}.Release|Any CPU.Build.0 = Release|Any CPU + {23695A6A-DC6C-44F0-99DF-8570AC9118F2}.Release|x64.ActiveCfg = Release|Any CPU + {23695A6A-DC6C-44F0-99DF-8570AC9118F2}.Release|x64.Build.0 = Release|Any CPU + {23695A6A-DC6C-44F0-99DF-8570AC9118F2}.Release|x86.ActiveCfg = Release|Any CPU + {23695A6A-DC6C-44F0-99DF-8570AC9118F2}.Release|x86.Build.0 = Release|Any CPU {D90BE707-E2E4-4085-A09D-11BE81B379A5}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {D90BE707-E2E4-4085-A09D-11BE81B379A5}.Debug|Any CPU.Build.0 = Debug|Any CPU + {D90BE707-E2E4-4085-A09D-11BE81B379A5}.Debug|x64.ActiveCfg = Debug|Any CPU + {D90BE707-E2E4-4085-A09D-11BE81B379A5}.Debug|x64.Build.0 = Debug|Any CPU + {D90BE707-E2E4-4085-A09D-11BE81B379A5}.Debug|x86.ActiveCfg = Debug|Any CPU + {D90BE707-E2E4-4085-A09D-11BE81B379A5}.Debug|x86.Build.0 = Debug|Any CPU {D90BE707-E2E4-4085-A09D-11BE81B379A5}.Release|Any CPU.ActiveCfg = Release|Any CPU {D90BE707-E2E4-4085-A09D-11BE81B379A5}.Release|Any CPU.Build.0 = Release|Any CPU + {D90BE707-E2E4-4085-A09D-11BE81B379A5}.Release|x64.ActiveCfg = Release|Any CPU + {D90BE707-E2E4-4085-A09D-11BE81B379A5}.Release|x64.Build.0 = Release|Any CPU + {D90BE707-E2E4-4085-A09D-11BE81B379A5}.Release|x86.ActiveCfg = Release|Any CPU + {D90BE707-E2E4-4085-A09D-11BE81B379A5}.Release|x86.Build.0 = Release|Any CPU + {38D36222-9B32-446B-9B40-4C0CB6958EA8}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {38D36222-9B32-446B-9B40-4C0CB6958EA8}.Debug|Any CPU.Build.0 = Debug|Any CPU + {38D36222-9B32-446B-9B40-4C0CB6958EA8}.Debug|x64.ActiveCfg = Debug|Any CPU + {38D36222-9B32-446B-9B40-4C0CB6958EA8}.Debug|x64.Build.0 = Debug|Any CPU + {38D36222-9B32-446B-9B40-4C0CB6958EA8}.Debug|x86.ActiveCfg = Debug|Any CPU + {38D36222-9B32-446B-9B40-4C0CB6958EA8}.Debug|x86.Build.0 = Debug|Any CPU + {38D36222-9B32-446B-9B40-4C0CB6958EA8}.Release|Any CPU.ActiveCfg = Release|Any CPU + {38D36222-9B32-446B-9B40-4C0CB6958EA8}.Release|Any CPU.Build.0 = Release|Any CPU + {38D36222-9B32-446B-9B40-4C0CB6958EA8}.Release|x64.ActiveCfg = Release|Any CPU + {38D36222-9B32-446B-9B40-4C0CB6958EA8}.Release|x64.Build.0 = Release|Any CPU + {38D36222-9B32-446B-9B40-4C0CB6958EA8}.Release|x86.ActiveCfg = Release|Any CPU + {38D36222-9B32-446B-9B40-4C0CB6958EA8}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -115,6 +238,7 @@ Global {B09CFDAC-19CB-48F2-B7F7-03A47430C707} = {66320409-64EC-F7C5-3DEF-65E7510DAAD1} {23695A6A-DC6C-44F0-99DF-8570AC9118F2} = {580E3446-6579-4414-9875-970849E635E5} {D90BE707-E2E4-4085-A09D-11BE81B379A5} = {742442F2-CAE7-4DC8-BD73-8C54C0005A53} + {38D36222-9B32-446B-9B40-4C0CB6958EA8} = {742442F2-CAE7-4DC8-BD73-8C54C0005A53} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {10834736-59FD-47FF-9344-096247DC48CD} diff --git a/test/Dapper.FluentMap.ProviderCompatibility.Tests/AssemblyInfo.cs b/test/Dapper.FluentMap.ProviderCompatibility.Tests/AssemblyInfo.cs new file mode 100644 index 0000000..2171200 --- /dev/null +++ b/test/Dapper.FluentMap.ProviderCompatibility.Tests/AssemblyInfo.cs @@ -0,0 +1,3 @@ +using Xunit; + +[assembly: CollectionBehavior(DisableTestParallelization = true)] diff --git a/test/Dapper.FluentMap.ProviderCompatibility.Tests/Dapper.FluentMap.ProviderCompatibility.Tests.csproj b/test/Dapper.FluentMap.ProviderCompatibility.Tests/Dapper.FluentMap.ProviderCompatibility.Tests.csproj new file mode 100644 index 0000000..3db35b4 --- /dev/null +++ b/test/Dapper.FluentMap.ProviderCompatibility.Tests/Dapper.FluentMap.ProviderCompatibility.Tests.csproj @@ -0,0 +1,20 @@ + + + net10.0 + false + + + + + + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + diff --git a/test/Dapper.FluentMap.ProviderCompatibility.Tests/ProviderCompatibilityTests.cs b/test/Dapper.FluentMap.ProviderCompatibility.Tests/ProviderCompatibilityTests.cs new file mode 100644 index 0000000..982175f --- /dev/null +++ b/test/Dapper.FluentMap.ProviderCompatibility.Tests/ProviderCompatibilityTests.cs @@ -0,0 +1,890 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations.Schema; +using System.Data; +using System.Data.Common; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Dapper; +using Dapper.FluentMap.Dommel; +using Dapper.FluentMap.Dommel.Mapping; +using Dapper.FluentMap.Mapping; +using Dommel; +using Microsoft.Data.SqlClient; +using Microsoft.Data.Sqlite; +using Npgsql; +using Xunit; + +namespace Dapper.FluentMap.ProviderCompatibility.Tests +{ + public class ProviderCompatibilityTests + { + public static IEnumerable Providers() + { + yield return new object[] { ProviderCase.Sqlite() }; + yield return new object[] { ProviderCase.SqlServer() }; + yield return new object[] { ProviderCase.PostgreSql() }; + } + + [Theory] + [MemberData(nameof(Providers))] + [Trait("Category", "ProviderCompatibility")] + public void BasicReadShouldMaterializeProviderValues(ProviderCase provider) + { + provider.SkipIfUnavailable(); + PreTest(typeof(BasicProviderCustomer)); + + try + { + FluentMapper.Initialize(configuration => configuration.AddMap(new BasicProviderCustomerMap())); + + using (var connection = provider.OpenConnection()) + { + var tableName = provider.CreateTableName("basic"); + provider.DropTable(connection, tableName); + provider.Execute(connection, provider.CreateBasicTableSql(tableName)); + + var expectedGuid = Guid.Parse("84e705f9-81a7-4c92-bf35-16310e29c5f2"); + var expectedDate = new DateTime(2026, 7, 29, 12, 30, 45); + var expectedBalance = 1234.56m; + + connection.Execute( + provider.InsertBasicSql(tableName), + new + { + CustomerId = 42, + OptionalName = (string)null, + ExternalId = provider.GuidParameter(expectedGuid), + CreatedAt = expectedDate, + Balance = expectedBalance + }); + + var customer = connection.QueryMappedSingle( + provider.SelectBasicSql(tableName)); + + Assert.Equal(42, customer.Id); + Assert.Null(customer.OptionalName); + Assert.Equal(expectedGuid, customer.ExternalId); + Assert.Equal(expectedDate, customer.CreatedAt); + Assert.Equal(expectedBalance, customer.Balance); + } + } + finally + { + PreTest(typeof(BasicProviderCustomer)); + } + } + + [Theory] + [MemberData(nameof(Providers))] + [Trait("Category", "ProviderCompatibility")] + public void AdvancedReadShouldMaterializeConstructorNestedValueObjectProfileAndConverter(ProviderCase provider) + { + provider.SkipIfUnavailable(); + PreTest(typeof(AdvancedProviderCustomer)); + + try + { + FluentMapper.Initialize(configuration => + { + configuration.AddMap(new AdvancedProviderCustomerMap()); + configuration.AddProfile(); + }); + + using (var connection = provider.OpenConnection()) + { + var current = connection.QueryMappedSingle( + provider.SelectAdvancedSql( + "customer_id", + "city", + "email", + "status", + 7, + "Sao Paulo", + "ada@example.com", + "A")); + var legacy = connection.QueryMappedSingle( + provider.SelectAdvancedSql( + "legacy_id", + "legacy_city", + "legacy_email", + "legacy_status", + 8, + "Porto", + "legacy@example.com", + "I")); + + Assert.Equal(7, current.Id); + Assert.NotNull(current.Address); + Assert.Equal("Sao Paulo", current.Address.City); + Assert.Equal(new ProviderEmail("ada@example.com"), current.Email); + Assert.Equal(AccountStatus.Active, current.Status); + + Assert.Equal(8, legacy.Id); + Assert.NotNull(legacy.Address); + Assert.Equal("Porto", legacy.Address.City); + Assert.Equal(new ProviderEmail("legacy@example.com"), legacy.Email); + Assert.Equal(AccountStatus.Inactive, legacy.Status); + } + } + finally + { + PreTest(typeof(AdvancedProviderCustomer)); + } + } + + [Theory] + [MemberData(nameof(Providers))] + [Trait("Category", "ProviderCompatibility")] + public void QueryMultipleMappedShouldReadSequentialProviderResultSets(ProviderCase provider) + { + provider.SkipIfUnavailable(); + provider.SkipIfMultipleResultsUnsupported(); + PreTest(typeof(MultipleCustomer), typeof(MultipleOrder)); + + try + { + FluentMapper.Initialize(configuration => + { + configuration.AddMap(new MultipleCustomerMap()); + configuration.AddMap(new MultipleOrderMap()); + }); + + using (var connection = provider.OpenConnection()) + using (var multi = connection.QueryMultipleMapped(provider.MultipleResultsSql())) + { + var customer = multi.ReadMappedSingle(); + var order = multi.ReadMappedSingle(); + + Assert.Equal(11, customer.Id); + Assert.Equal("Multiple", customer.Name); + Assert.Equal(99, order.Id); + Assert.Equal(12.34m, order.Total); + Assert.True(multi.IsConsumed); + } + } + finally + { + PreTest(typeof(MultipleCustomer), typeof(MultipleOrder)); + } + } + + [Theory] + [MemberData(nameof(Providers))] + [Trait("Category", "ProviderCompatibility")] + public void UnbufferedStreamingShouldKeepReaderOpenAndReleaseOnEarlyTermination(ProviderCase provider) + { + provider.SkipIfUnavailable(); + PreTest(typeof(StreamCustomer)); + + try + { + FluentMapper.Initialize(configuration => configuration.AddMap(new StreamCustomerMap())); + + using (var connection = provider.CreateClosedConnection()) + using (var enumerator = connection.QueryMappedUnbuffered( + provider.StreamingRowsSql()).GetEnumerator()) + { + Assert.True(enumerator.MoveNext()); + Assert.Equal(1, enumerator.Current.Id); + Assert.Equal(ConnectionState.Open, connection.State); + + enumerator.Dispose(); + + Assert.Equal(ConnectionState.Closed, connection.State); + } + } + finally + { + PreTest(typeof(StreamCustomer)); + } + } + + [Theory] + [MemberData(nameof(Providers))] + [Trait("Category", "ProviderCompatibility")] + public async Task AsyncStreamingShouldPropagateCancellationAndReleaseReader(ProviderCase provider) + { + provider.SkipIfUnavailable(); + PreTest(typeof(StreamCustomer)); + + try + { + FluentMapper.Initialize(configuration => configuration.AddMap(new StreamCustomerMap())); + + using (var connection = provider.CreateClosedConnection()) + using (var cancellation = new CancellationTokenSource()) + { + await using var enumerator = connection.QueryMappedUnbufferedAsync( + provider.StreamingRowsSql(), + cancellation.Token) + .GetAsyncEnumerator(cancellation.Token); + + Assert.True(await enumerator.MoveNextAsync()); + Assert.Equal(1, enumerator.Current.Id); + Assert.Equal(ConnectionState.Open, connection.State); + + cancellation.Cancel(); + + await Assert.ThrowsAsync(async () => + { + await enumerator.MoveNextAsync(); + }); + + Assert.Equal(ConnectionState.Closed, connection.State); + } + } + finally + { + PreTest(typeof(StreamCustomer)); + } + } + + [Theory] + [MemberData(nameof(Providers))] + [Trait("Category", "ProviderCompatibility")] + public void DommelPersistenceShouldHonorGeneratedDefaultsAndReadOnlyMetadata(ProviderCase provider) + { + provider.SkipIfUnavailable(); + provider.SkipIfPersistenceUnsupported(); + PreTest(typeof(ProviderPersistenceEntity)); + + try + { + provider.InitializeNativeProvider(); + FluentMapper.Initialize(configuration => + { + configuration.AddMap(new ProviderPersistenceEntityMap()); + configuration.ForDommel(); + }); + + using (var connection = provider.OpenConnection()) + { + provider.DropTable(connection, ProviderPersistenceEntityMap.MappedTableName); + provider.Execute(connection, provider.CreatePersistenceTableSql(ProviderPersistenceEntityMap.MappedTableName)); + + var entity = new ProviderPersistenceEntity + { + Normal = "inserted", + ReadOnly = "client-read-only", + DefaultValue = "client-default", + Computed = "client-computed" + }; + + var id = Convert.ToInt32(connection.Insert(entity)); + var inserted = connection.Get(id); + + Assert.Equal("inserted", inserted.Normal); + Assert.Equal("read-only-default", inserted.ReadOnly); + Assert.Equal("default-value-default", inserted.DefaultValue); + Assert.Equal("inserted-computed", inserted.Computed); + + entity.Id = id; + entity.Normal = "updated"; + entity.ReadOnly = "updated-read-only"; + entity.DefaultValue = "updated-default"; + entity.Computed = "updated-computed"; + + Assert.True(connection.Update(entity)); + + var updated = connection.Get(id); + Assert.Equal("updated", updated.Normal); + Assert.Equal("read-only-default", updated.ReadOnly); + Assert.Equal("updated-default", updated.DefaultValue); + Assert.Equal("updated-computed", updated.Computed); + } + } + finally + { + PreTest(typeof(ProviderPersistenceEntity)); + } + } + + [Theory] + [MemberData(nameof(Providers))] + [Trait("Category", "ProviderCompatibility")] + public void DommelPersistenceShouldInsertNonIdentityKeyAndKeepItOutOfUpdateSet(ProviderCase provider) + { + provider.SkipIfUnavailable(); + provider.SkipIfPersistenceUnsupported(); + PreTest(typeof(ProviderAssignedKeyEntity)); + + try + { + provider.InitializeNativeProvider(); + FluentMapper.Initialize(configuration => + { + configuration.AddMap(new ProviderAssignedKeyEntityMap()); + configuration.ForDommel(); + }); + + using (var connection = provider.OpenConnection()) + { + provider.DropTable(connection, ProviderAssignedKeyEntityMap.MappedTableName); + provider.Execute(connection, provider.CreateAssignedKeyTableSql(ProviderAssignedKeyEntityMap.MappedTableName)); + + var entity = new ProviderAssignedKeyEntity + { + Code = "A-001", + Name = "inserted", + UpdateExcluded = "insert-write" + }; + + connection.Insert(entity); + + var inserted = connection.QuerySingle( + provider.SelectAssignedKeySql(ProviderAssignedKeyEntityMap.MappedTableName), + new { entity.Code }); + + Assert.Equal("A-001", inserted.Code); + Assert.Equal("inserted", inserted.Name); + Assert.Equal("insert-write", inserted.UpdateExcluded); + + entity.Name = "updated"; + entity.UpdateExcluded = "update-write"; + + Assert.True(connection.Update(entity)); + + var updated = connection.QuerySingle( + provider.SelectAssignedKeySql(ProviderAssignedKeyEntityMap.MappedTableName), + new { entity.Code }); + + Assert.Equal("A-001", updated.Code); + Assert.Equal("updated", updated.Name); + Assert.Equal("insert-write", updated.UpdateExcluded); + } + } + finally + { + PreTest(typeof(ProviderAssignedKeyEntity)); + } + } + + private static void PreTest(params Type[] types) + { + FluentMapper.EntityMaps.Clear(); + FluentMapper.TypeConventions.Clear(); + FluentMapper.Initialize(_ => { }); + DommelMapper.LogReceived = null; + } + + public sealed class ProviderCase + { + private readonly string connectionString; + private readonly Func connectionFactory; + + private ProviderCase( + string name, + string connectionStringEnvironmentVariable, + string connectionString, + Func connectionFactory, + ProviderDialect dialect, + bool isAlwaysAvailable = false, + bool supportsMultipleResults = true, + bool supportsPersistence = true) + { + Name = name; + ConnectionStringEnvironmentVariable = connectionStringEnvironmentVariable; + this.connectionString = connectionString; + this.connectionFactory = connectionFactory; + Dialect = dialect; + IsAlwaysAvailable = isAlwaysAvailable; + SupportsMultipleResults = supportsMultipleResults; + SupportsPersistence = supportsPersistence; + } + + public string Name { get; } + + public string ConnectionStringEnvironmentVariable { get; } + + public ProviderDialect Dialect { get; } + + public bool IsAlwaysAvailable { get; } + + public bool SupportsMultipleResults { get; } + + public bool SupportsPersistence { get; } + + public static ProviderCase Sqlite() + { + return new ProviderCase( + "SQLite", + null, + "Data Source=:memory:", + connectionString => new SqliteConnection(connectionString), + ProviderDialect.Sqlite, + isAlwaysAvailable: true); + } + + public static ProviderCase SqlServer() + { + const string environmentVariable = "DFM_SQLSERVER_CONNECTION_STRING"; + return new ProviderCase( + "SQL Server", + environmentVariable, + Environment.GetEnvironmentVariable(environmentVariable), + connectionString => new SqlConnection(connectionString), + ProviderDialect.SqlServer); + } + + public static ProviderCase PostgreSql() + { + const string environmentVariable = "DFM_POSTGRESQL_CONNECTION_STRING"; + return new ProviderCase( + "PostgreSQL", + environmentVariable, + Environment.GetEnvironmentVariable(environmentVariable), + connectionString => new NpgsqlConnection(connectionString), + ProviderDialect.PostgreSql); + } + + public override string ToString() + { + return Name; + } + + public void SkipIfUnavailable() + { + if (!IsAlwaysAvailable && string.IsNullOrWhiteSpace(connectionString)) + { + Assert.Skip(Name + " provider tests require " + ConnectionStringEnvironmentVariable + "."); + } + } + + public void SkipIfMultipleResultsUnsupported() + { + if (!SupportsMultipleResults) + { + Assert.Skip(Name + " does not expose equivalent multiple-result behavior through this provider."); + } + } + + public void SkipIfPersistenceUnsupported() + { + if (!SupportsPersistence) + { + Assert.Skip(Name + " persistence is unsupported by the current Dommel/provider combination."); + } + } + + public void InitializeNativeProvider() + { + if (Dialect == ProviderDialect.Sqlite) + { + SQLitePCL.Batteries_V2.Init(); + } + } + + public DbConnection CreateClosedConnection() + { + InitializeNativeProvider(); + return connectionFactory(connectionString); + } + + public DbConnection OpenConnection() + { + var connection = CreateClosedConnection(); + connection.Open(); + return connection; + } + + public string CreateTableName(string prefix) + { + return "dfm_" + prefix + "_" + Guid.NewGuid().ToString("N"); + } + + public object GuidParameter(Guid value) + { + return Dialect == ProviderDialect.Sqlite ? value.ToString() : (object)value; + } + + public void Execute(IDbConnection connection, string sql) + { + connection.Execute(sql); + } + + public void DropTable(IDbConnection connection, string tableName) + { + switch (Dialect) + { + case ProviderDialect.SqlServer: + connection.Execute("IF OBJECT_ID(N'" + tableName + "', N'U') IS NOT NULL DROP TABLE " + tableName + ";"); + break; + default: + connection.Execute("DROP TABLE IF EXISTS " + tableName + ";"); + break; + } + } + + public string CreateBasicTableSql(string tableName) + { + switch (Dialect) + { + case ProviderDialect.SqlServer: + return @"CREATE TABLE " + tableName + @" ( + customer_id INT NOT NULL, + optional_name NVARCHAR(100) NULL, + external_id UNIQUEIDENTIFIER NOT NULL, + created_at DATETIME2 NOT NULL, + balance DECIMAL(18, 2) NOT NULL +);"; + case ProviderDialect.PostgreSql: + return @"CREATE TABLE " + tableName + @" ( + customer_id INTEGER NOT NULL, + optional_name TEXT NULL, + external_id UUID NOT NULL, + created_at TIMESTAMP NOT NULL, + balance NUMERIC(18, 2) NOT NULL +);"; + default: + return @"CREATE TABLE " + tableName + @" ( + customer_id INTEGER NOT NULL, + optional_name TEXT NULL, + external_id TEXT NOT NULL, + created_at TEXT NOT NULL, + balance TEXT NOT NULL +);"; + } + } + + public string InsertBasicSql(string tableName) + { + return "INSERT INTO " + tableName + @" ( + customer_id, + optional_name, + external_id, + created_at, + balance +) VALUES ( + @CustomerId, + @OptionalName, + @ExternalId, + @CreatedAt, + @Balance +);"; + } + + public string SelectBasicSql(string tableName) + { + return @"SELECT + customer_id, + optional_name, + external_id, + created_at, + balance +FROM " + tableName + ";"; + } + + public string SelectAdvancedSql( + string idAlias, + string cityAlias, + string emailAlias, + string statusAlias, + int id, + string city, + string email, + string status) + { + return "SELECT " + + Literal(id) + " AS " + idAlias + ", " + + TextLiteral(city) + " AS " + cityAlias + ", " + + TextLiteral(email) + " AS " + emailAlias + ", " + + TextLiteral(status) + " AS " + statusAlias + ";"; + } + + public string MultipleResultsSql() + { + return "SELECT 11 AS customer_id, " + TextLiteral("Multiple") + " AS customer_name; " + + "SELECT 99 AS order_id, " + DecimalLiteral(12.34m) + " AS total;"; + } + + public string StreamingRowsSql() + { + return "SELECT 1 AS customer_id, " + TextLiteral("One") + " AS customer_name UNION ALL " + + "SELECT 2 AS customer_id, " + TextLiteral("Two") + " AS customer_name;"; + } + + public string CreatePersistenceTableSql(string tableName) + { + switch (Dialect) + { + case ProviderDialect.SqlServer: + return @"CREATE TABLE " + tableName + @" ( + id INT IDENTITY(1, 1) NOT NULL PRIMARY KEY, + normal NVARCHAR(100) NOT NULL, + read_only NVARCHAR(100) NOT NULL CONSTRAINT DF_" + tableName + @"_read_only DEFAULT N'read-only-default', + default_value NVARCHAR(100) NOT NULL CONSTRAINT DF_" + tableName + @"_default_value DEFAULT N'default-value-default', + computed AS (normal + N'-computed') +);"; + case ProviderDialect.PostgreSql: + return @"CREATE TABLE " + tableName + @" ( + id INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + normal TEXT NOT NULL, + read_only TEXT NOT NULL DEFAULT 'read-only-default', + default_value TEXT NOT NULL DEFAULT 'default-value-default', + computed TEXT GENERATED ALWAYS AS (normal || '-computed') STORED +);"; + default: + return @"CREATE TABLE " + tableName + @" ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + normal TEXT NOT NULL, + read_only TEXT NOT NULL DEFAULT 'read-only-default', + default_value TEXT NOT NULL DEFAULT 'default-value-default', + computed TEXT GENERATED ALWAYS AS (normal || '-computed') STORED +);"; + } + } + + public string CreateAssignedKeyTableSql(string tableName) + { + switch (Dialect) + { + case ProviderDialect.SqlServer: + return @"CREATE TABLE " + tableName + @" ( + code NVARCHAR(32) NOT NULL PRIMARY KEY, + name NVARCHAR(100) NOT NULL, + update_excluded NVARCHAR(100) NULL +);"; + case ProviderDialect.PostgreSql: + case ProviderDialect.Sqlite: + default: + return @"CREATE TABLE " + tableName + @" ( + code TEXT NOT NULL PRIMARY KEY, + name TEXT NOT NULL, + update_excluded TEXT NULL +);"; + } + } + + public string SelectAssignedKeySql(string tableName) + { + return @"SELECT + code AS Code, + name AS Name, + update_excluded AS UpdateExcluded +FROM " + tableName + @" +WHERE code = @Code;"; + } + + private string Literal(int value) + { + return value.ToString(System.Globalization.CultureInfo.InvariantCulture); + } + + private string DecimalLiteral(decimal value) + { + return value.ToString(System.Globalization.CultureInfo.InvariantCulture); + } + + private string TextLiteral(string value) + { + return "'" + value.Replace("'", "''") + "'"; + } + } + + public enum ProviderDialect + { + Sqlite, + SqlServer, + PostgreSql + } + + private sealed class BasicProviderCustomer + { + public int Id { get; set; } + + public string OptionalName { get; set; } + + public Guid ExternalId { get; set; } + + public DateTime CreatedAt { get; set; } + + public decimal Balance { get; set; } + } + + private sealed class BasicProviderCustomerMap : EntityMap + { + public BasicProviderCustomerMap() + { + Map(customer => customer.Id).ToColumn("customer_id"); + Map(customer => customer.OptionalName).ToColumn("optional_name"); + Map(customer => customer.ExternalId).ToColumn("external_id"); + Map(customer => customer.CreatedAt).ToColumn("created_at"); + Map(customer => customer.Balance).ToColumn("balance"); + } + } + + private sealed class LegacyProfile : IMappingProfile + { + } + + private enum AccountStatus + { + Unknown, + Active, + Inactive + } + + private sealed class AdvancedProviderCustomer + { + public AdvancedProviderCustomer(int id, ProviderAddress address, ProviderEmail email, AccountStatus status) + { + Id = id; + Address = address; + Email = email; + Status = status; + } + + public int Id { get; } + + public ProviderAddress Address { get; } + + public ProviderEmail Email { get; } + + public AccountStatus Status { get; } + } + + private sealed class ProviderAddress + { + public ProviderAddress(string city) + { + City = city; + } + + public string City { get; } + } + + private sealed record ProviderEmail(string Value); + + private sealed class AdvancedProviderCustomerMap : EntityMap + { + public AdvancedProviderCustomerMap() + { + Map(customer => customer.Id).ToColumn("customer_id"); + Map(customer => customer.Address.City).ToColumn("city"); + Map(customer => customer.Email.Value).ToColumn("email"); + Map(customer => customer.Status).ToColumn("status").ConvertFromDatabaseUsing(); + } + } + + private sealed class LegacyAdvancedProviderCustomerMap : + EntityMap, + IProfileMap + { + public LegacyAdvancedProviderCustomerMap() + { + Map(customer => customer.Id).ToColumn("legacy_id"); + Map(customer => customer.Address.City).ToColumn("legacy_city"); + Map(customer => customer.Email.Value).ToColumn("legacy_email"); + Map(customer => customer.Status).ToColumn("legacy_status").ConvertFromDatabaseUsing(); + } + } + + private sealed class StatusConverter : IReadPropertyConverter + { + public AccountStatus ConvertFromDatabase(string value) + { + return value == "A" ? AccountStatus.Active : AccountStatus.Inactive; + } + } + + private sealed class MultipleCustomer + { + public int Id { get; set; } + + public string Name { get; set; } + } + + private sealed class MultipleCustomerMap : EntityMap + { + public MultipleCustomerMap() + { + Map(customer => customer.Id).ToColumn("customer_id"); + Map(customer => customer.Name).ToColumn("customer_name"); + } + } + + private sealed class MultipleOrder + { + public int Id { get; set; } + + public decimal Total { get; set; } + } + + private sealed class MultipleOrderMap : EntityMap + { + public MultipleOrderMap() + { + Map(order => order.Id).ToColumn("order_id"); + Map(order => order.Total).ToColumn("total"); + } + } + + private sealed class StreamCustomer + { + public int Id { get; set; } + + public string Name { get; set; } + } + + private sealed class StreamCustomerMap : EntityMap + { + public StreamCustomerMap() + { + Map(customer => customer.Id).ToColumn("customer_id"); + Map(customer => customer.Name).ToColumn("customer_name"); + } + } + + private sealed class ProviderPersistenceEntity + { + public int Id { get; set; } + + public string Normal { get; set; } + + public string ReadOnly { get; set; } + + public string DefaultValue { get; set; } + + public string Computed { get; set; } + } + + private sealed class ProviderPersistenceEntityMap : DommelEntityMap + { + public const string MappedTableName = "dfm_provider_persistence"; + + public ProviderPersistenceEntityMap() + { + ToTable(MappedTableName); + Map(entity => entity.Id).ToColumn("id").IsIdentity(); + Map(entity => entity.Normal).ToColumn("normal"); + Map(entity => entity.ReadOnly).ToColumn("read_only").ReadOnly(); + Map(entity => entity.DefaultValue).ToColumn("default_value").DatabaseDefaultOnInsert(); + Map(entity => entity.Computed).ToColumn("computed").Computed(); + } + } + + private sealed class ProviderAssignedKeyEntity + { + public string Code { get; set; } + + public string Name { get; set; } + + public string UpdateExcluded { get; set; } + } + + private sealed class ProviderAssignedKeyEntityMap : DommelEntityMap + { + public const string MappedTableName = "dfm_provider_assigned_key"; + + public ProviderAssignedKeyEntityMap() + { + ToTable(MappedTableName); + Map(entity => entity.Code).ToColumn("code").IsKey().SetGeneratedOption(DatabaseGeneratedOption.None); + Map(entity => entity.Name).ToColumn("name"); + Map(entity => entity.UpdateExcluded).ToColumn("update_excluded").ExcludeFromUpdate(); + } + } + } +} From 7b6ac29b89c10c1c2c300a15f07b6344dd0d0dfe Mon Sep 17 00:00:00 2001 From: Rodrigo de Oliveira Date: Wed, 29 Jul 2026 11:24:14 -0300 Subject: [PATCH 39/49] build(packaging): harden NuGet package validation --- .sdd/etapa-12/02-compatibility-spec.md | 2 +- .sdd/etapa-12/03-compatibility-matrix.md | 12 + .sdd/etapa-12/05-public-api-review.md | 436 ++++++++++++++++++ .sdd/etapa-12/DECISIONS.md | 88 ++++ .sdd/etapa-12/STATUS.md | 89 +++- Directory.Build.props | 16 + .../Dapper.FluentMap.Analyzers.csproj | 5 +- ...apper.FluentMap.DependencyInjection.csproj | 4 +- .../Dapper.FluentMap.Dommel.csproj | 11 +- .../Dapper.FluentMap.Generators.csproj | 5 +- src/Dapper.FluentMap/Dapper.FluentMap.csproj | 9 +- 11 files changed, 649 insertions(+), 28 deletions(-) create mode 100644 .sdd/etapa-12/05-public-api-review.md diff --git a/.sdd/etapa-12/02-compatibility-spec.md b/.sdd/etapa-12/02-compatibility-spec.md index 40d419c..24144c3 100644 --- a/.sdd/etapa-12/02-compatibility-spec.md +++ b/.sdd/etapa-12/02-compatibility-spec.md @@ -37,7 +37,7 @@ Politica proposta: Politica proposta: -- Suporte minimo atual: `Dommel >= 3.5.3`. +- Suporte atual declarado no pacote: `Dommel [3.5.3,4.0.0)`. - Dommel e pacote opcional e nao faz parte do contrato do core. - A integracao Dommel atual e process-wide porque usa extension points globais de `DommelMapper`. diff --git a/.sdd/etapa-12/03-compatibility-matrix.md b/.sdd/etapa-12/03-compatibility-matrix.md index 2fef071..0ec1698 100644 --- a/.sdd/etapa-12/03-compatibility-matrix.md +++ b/.sdd/etapa-12/03-compatibility-matrix.md @@ -30,6 +30,18 @@ pre-releases antigas da linha `1.x`, sem valor para a matriz de release atual. Dommel permanece pacote opcional e bridge process-wide. A matriz do core nao promete isolamento Dommel por runtime. +A dependencia NuGet declarada para Dommel e: + +```xml +[3.5.3,4.0.0) +``` + +Rationale: + +- `3.5.3` e a versao minima e latest stable validada no Prompt 12.2. +- O limite exclusivo `4.0.0` evita que um major futuro nao validado seja + selecionado automaticamente por consumidores. + ## Analyzer And Generator Components | Component | TFM | Compiler References | Build | Tests | Status | diff --git a/.sdd/etapa-12/05-public-api-review.md b/.sdd/etapa-12/05-public-api-review.md new file mode 100644 index 0000000..02e9764 --- /dev/null +++ b/.sdd/etapa-12/05-public-api-review.md @@ -0,0 +1,436 @@ +# Public API Review + +Revisao executada em 2026-07-29 para o Prompt 12.4, no checkout local +`feature/etapa-3`. + +## Inputs + +- Assemblies locais Release em `src/*/bin/Release/netstandard2.0`. +- Pacotes locais em `artifacts/packages-12.4-final`. +- Pacotes historicos NuGet.org `Dapper.FluentMap` `2.0.0` e + `Dapper.FluentMap.Dommel` `2.0.0`. +- NuGet.org mostrou `Dapper.FluentMap` e `Dapper.FluentMap.Dommel` `2.0.0` + como pacotes historicos/deprecated. Os pacotes `DependencyInjection`, + `Analyzers` e `Generators` nao tinham historico nesses IDs. + +## Tooling Decision + +Tooling adotado agora: + +- `EnablePackageValidation=true` nos pacotes com `lib/`: + `Dapper.FluentMap`, `Dapper.FluentMap.Dommel` e + `Dapper.FluentMap.DependencyInjection`. +- Package validation nativo do SDK roda durante `dotnet pack`. +- Baseline historico contra `2.0.0` original ficou documentado e opt-in para + core/Dommel via `EnableFluentMapHistoricalApiCompatValidation`, porque a API + do fork ja divergiu e uma baseline obrigatoria contra o pacote original nao + representa um gate verde realista para esta linha. + +Tooling nao adotado agora: + +- `PublicApiAnalyzers`: redundante neste momento com package validation nativo + do SDK e exigiria baseline textual grande antes da decisao de versao do fork. +- ApiCompat global tool permanente: nao necessario enquanto o SDK ja executa + package validation no pack. Pode ser usado pontualmente em auditorias. + +Decisao para release futura: + +- Depois do primeiro RC do fork, definir `PackageValidationBaselineVersion` + para a ultima versao aprovada do proprio fork e commitar suppressions apenas + quando uma quebra for intencional e documentada. + +## Dapper.FluentMap + +Resumo da superficie publica atual: + +- 51 tipos publicos e 298 membros publicos/protected observados. +- TFM: `netstandard2.0`. +- Assembly nao strong-named. +- Package validation nativo: habilitado. + +Public surface: + +- Facade global: `FluentMapper`, `FluentMapConfigurationException`. +- Runtime isolado: `FluentMapRuntime`, `FluentMapConfigurationBuilder`, + `ImmutableFluentMapConfiguration`. +- Configuracao: `FluentMapConfiguration`, `FluentConventionConfiguration` e + snapshots `EntityMappingConfiguration`, `ProfileMappingConfiguration`, + `ConventionMappingConfiguration`, `PropertyMappingConfiguration`, + `GeneratedMaterializerConfiguration`. +- Mapeamento: `EntityMap`, `EntityMapBase`, + `PropertyMap`, `PropertyMapBase`, `IEntityMap`, + `IEntityMap`, `IPropertyMap`, `IMappingProfile`, + `IProfileMap`. +- Conversao/persistencia: `IPropertyConverter<>`, + `IReadPropertyConverter<>`, `IWritePropertyConverter<>`, + `PropertyConversionMetadata`, `PropertyConverterMetadata`, + `PropertyPersistenceMetadata`, delegates de conversao. +- Materializacao gerada: `GeneratedMaterializerColumn`, + `GeneratedMaterializerDescriptor`, `GeneratedRowMaterializer`. +- Diagnosticos: `MappingExplanation`, `MemberMappingExplanation`, + `ConstructorParameterExplanation`, `MappingSource`, + `MappingMaterialization`. +- Convencoes/naming: `Convention`, `ConventionPropertyConfiguration`, + `PropertyConventionConfiguration`, `NamingPolicy`. +- Dapper integration/query helpers: `QueryMappedExtensions`, + `MappedGridReader`, `FluentMapTypeMap`, + `FluentConventionTypeMap`, `MultiTypeMap`. +- Utilities publicas historicas: `ReflectionHelper` e + `FluentMapConfigurationExtensions`. + +Newly introduced APIs versus `Dapper.FluentMap` `2.0.0` original: + +- Runtime/configuracao isolada (`FluentMapRuntime`, + `FluentMapConfigurationBuilder`, `ImmutableFluentMapConfiguration`). +- Profiles (`IMappingProfile`, `IProfileMap`, `AddProfile`). +- Naming policies (`NamingPolicy`, `UseNamingPolicy`). +- Query/materialization APIs (`QueryMapped*`, `QueryMultipleMapped`, + `MappedGridReader`, streaming sync/async). +- Generated materializer contract and registration APIs. +- Conversion metadata and converter interfaces/delegates. +- Persistence metadata (`ReadOnly`, `Computed`, database default, generated + and identity semantics). +- Diagnostics/explanation API. + +Obsolete APIs: + +- Nenhum `[Obsolete]` encontrado. + +Accidental API candidates: + +- `Dapper.FluentMap.Utils.ReflectionHelper` era publico no pacote original e + deve ser tratado como legado publico, mesmo parecendo utilitario interno. +- `Dapper.FluentMap.Utils.FluentMapConfigurationExtensions` e nova API publica + em namespace `Utils`; revisar antes de stable se deve permanecer contrato + publico. +- Type maps em `Dapper.FluentMap.TypeMaps` sao publicos historicos e servem + como extension points avancados; nao remover sem major. + +Extension points: + +- `IEntityMap`, `EntityMap`, + `EntityMapBase`. +- `Convention` e configuracoes de convencao. +- `IPropertyConverter<>`, `IReadPropertyConverter<>`, + `IWritePropertyConverter<>` e delegates. +- `FluentMapTypeMap`, `FluentConventionTypeMap` e + `MultiTypeMap` para integracao avancada com Dapper. + +Historical compatibility classification: + +- Compatible: tipos historicos principais continuam presentes. +- Compatible/additive: a maioria da superficie nova e aditiva. +- Behaviorally changed: validacao, materializacao aninhada, converters, + persistence metadata e query helpers alteram capacidades observaveis. +- Source breaking: nenhum membro historico removido foi confirmado no core; os + headers de `PropertyMap`/`PropertyMapBase` mudaram por interfaces adicionais. +- Binary breaking: nao prometer compatibilidade absoluta; interfaces publicas + adicionais em tipos historicos geralmente sao aditivas, mas a linha do fork + ainda exige ApiCompat formal contra uma baseline aprovada do proprio fork. +- Intentionally replaced: assembly scanning continua existindo, mas registro + explicito/gerado e runtime isolado sao caminhos preferenciais novos. + +## Dapper.FluentMap.Dommel + +Resumo da superficie publica atual: + +- 8 tipos publicos e 23 membros publicos/protected observados. +- TFM: `netstandard2.0`. +- Assembly nao strong-named. +- Package validation nativo: habilitado. + +Public surface: + +- `FluentMapConfigurationExtensions.ForDommel()`. +- `DommelEntityMap`, `DommelPropertyMap`, `IDommelEntityMap`. +- Resolvers publicos: `DommelColumnNameResolver`, + `DommelKeyPropertyResolver`, `DommelPropertyResolver`, + `DommelTableNameResolver`. + +Newly introduced APIs versus `Dapper.FluentMap.Dommel` `2.0.0` original: + +- `DommelPropertyMap` agora tambem implementa metadata de conversion/persistence + herdada do core. +- Metodos de persistencia Dommel/core ampliados por `PropertyMapBase`. + +Obsolete APIs: + +- Nenhum `[Obsolete]` encontrado. + +Accidental API candidates: + +- Resolvers sao publicos desde a linha historica e devem ser tratados como + extension points publicos, mesmo quando usados principalmente pelo bridge. + +Extension points: + +- `DommelEntityMap` para mapas Dommel. +- `DommelPropertyMap` para key/identity/generated options. +- Resolvers Dommel publicos para integracao com extension points globais do + Dommel. + +Historical compatibility classification: + +- Compatible: tipos principais e `ForDommel()` continuam presentes. +- Behaviorally changed: persistence metadata agora interage com key/identity, + computed/default/read-only semantics. +- Source breaking: `DommelPropertyMap.GeneratedOption` mudou de + `DatabaseGeneratedOption` para `DatabaseGeneratedOption?`; consumidores que + assumem tipo nao-nullable podem precisar ajuste. +- Binary breaking: a mudanca de tipo de `GeneratedOption` altera assinatura de + getter/setter e e quebra binaria frente ao pacote original `2.0.0`. +- Intentionally replaced: nenhum pacote substituto; Dommel continua opcional e + process-wide. + +## Dapper.FluentMap.DependencyInjection + +Resumo da superficie publica atual: + +- 1 tipo publico e 1 metodo publico observado. +- TFM: `netstandard2.0`. +- Assembly nao strong-named. +- Package validation nativo: habilitado. +- Nao ha pacote historico NuGet.org nesse ID. + +Public surface: + +- `Microsoft.Extensions.DependencyInjection.FluentMapServiceCollectionExtensions`. +- `IServiceCollection AddFluentMap(Action)`. + +Newly introduced APIs: + +- Pacote novo do fork; toda a superficie e nova. + +Obsolete APIs: + +- Nenhum `[Obsolete]` encontrado. + +Accidental API candidates: + +- Nenhum candidato atual. O namespace `Microsoft.Extensions.DependencyInjection` + e intencional para extension method discovery. + +Extension points: + +- Callback de configuracao via `FluentMapConfigurationBuilder`. + +Historical compatibility classification: + +- Intentionally replaced/new: pacote novo, sem baseline historica. +- Binary compatibility futura deve ser medida contra o primeiro RC/estavel do + fork. + +## Dapper.FluentMap.Analyzers + +Resumo da superficie publica atual: + +- 1 tipo publico e 13 membros publicos/protected observados. +- TFM: `netstandard2.0`. +- Assembly nao strong-named. +- Layout NuGet: `analyzers/dotnet/cs`. +- Nao ha dependencias runtime no nuspec por causa de + `SuppressDependenciesWhenPacking=true` e `PrivateAssets=all`. +- Nao ha pacote historico NuGet.org nesse ID. + +Public surface: + +- `FluentMapConfigurationAnalyzer : DiagnosticAnalyzer`. +- `SupportedDiagnostics`, `Initialize`. +- Diagnostic IDs publicos: `DFM001`-`DFM015` conforme manifests/regras atuais. + +Newly introduced APIs: + +- Pacote novo do fork; toda a superficie e nova. + +Obsolete APIs: + +- Nenhum `[Obsolete]` encontrado. + +Accidental API candidates: + +- Campos publicos de diagnostic IDs sao contrato de usuario depois de publicado. + Manter estabilidade de ID/severity/categoria dentro da major. + +Extension points: + +- O analyzer class e carregado pelo compilador. Nao ha extensibilidade publica + planejada para consumidores alem de configurar severities no projeto + consumidor. + +Historical compatibility classification: + +- Intentionally replaced/new: pacote novo, sem baseline historica. +- Binary compatibility futura deve ser medida contra o primeiro RC/estavel do + fork. + +## Dapper.FluentMap.Generators + +Resumo da superficie publica atual: + +- 1 tipo publico intencional observado no fonte/assembly: + `MappingRegistrationGenerator : IIncrementalGenerator`. +- TFM: `netstandard2.0`. +- Assembly nao strong-named. +- Layout NuGet: `analyzers/dotnet/cs`. +- Nao ha dependencias runtime no nuspec por causa de + `SuppressDependenciesWhenPacking=true` e `PrivateAssets=all`. +- Nao ha pacote historico NuGet.org nesse ID. + +Public surface: + +- `MappingRegistrationGenerator : IIncrementalGenerator`. +- Metodo `Initialize(IncrementalGeneratorInitializationContext)`. +- API gerada para consumidores: `AddGeneratedMappings()` em codigo fonte + emitido durante compilacao. + +Newly introduced APIs: + +- Pacote novo do fork; toda a superficie e nova. +- O contrato mais importante para consumidores e o codigo gerado + `AddGeneratedMappings()`, nao o tipo do generator em si. + +Obsolete APIs: + +- Nenhum `[Obsolete]` encontrado. + +Accidental API candidates: + +- Nenhum candidato alem do proprio tipo generator, que precisa ser publico para + carregamento Roslyn. + +Extension points: + +- Nao ha extensibilidade publica planejada; consumidores influenciam o generator + declarando maps elegiveis no proprio projeto. + +Historical compatibility classification: + +- Intentionally replaced/new: pacote novo, sem baseline historica. +- Source compatibility futura deve incluir o shape de `AddGeneratedMappings()`. + +## NuGet Metadata Review + +Estado apos Prompt 12.4: + +- `PackageId`: preservado para os cinco pacotes. +- `VersionPrefix`: mantido em `2.0.0`; nao publicar essa versao porque core e + Dommel ja possuem `2.0.0` historico no NuGet.org. +- `Authors`: mantido como `Henk Mollema` para preservar atribuicao historica. +- `PackageProjectUrl`/`RepositoryUrl`: apontam para + `https://github.com/rodri-oliveira-dev/Dapper-FluentMap`. +- License: `PackageLicenseExpression=MIT`; `PackageLicenseUrl` removido de + core/Dommel. +- README: presente nos cinco `.nupkg`. +- Tags: preservadas por pacote. +- Release notes: nao adicionadas enquanto a versao/RC final nao estiver + decidida; release notes devem ser preenchidas junto do RC. +- Icon: nao adicionado porque nao ha ativo apropriado aprovado. +- Dependency ranges: + - Dapper: `[2.1.79,3.0.0)`. + - Dommel: `[3.5.3,4.0.0)`. + - Microsoft dependencies permanecem com minima atual, sem upper bound, por + serem contratos de plataforma/abstractions e nao haver evidencia de quebra + major especifica neste prompt. + +## Symbols, SourceLink And Determinism + +Estado apos Prompt 12.4: + +- `PublishRepositoryUrl=true`, `RepositoryType=git`, + `RepositoryUrl=https://github.com/rodri-oliveira-dev/Dapper-FluentMap`. +- `EmbedUntrackedSources=true`. +- `Deterministic=true`. +- `ContinuousIntegrationBuild=true` apenas quando `CI=true`. +- `IncludeSymbols=true` e `SymbolPackageFormat=snupkg` para pacotes runtime com + `lib/`. +- Analyzer/generator nao geram `.snupkg` porque esse layout nao possui `lib/`; + seus PDBs sao empacotados em `analyzers/dotnet/cs`. + +Validacao observada: + +- `.snupkg` gerado para core, Dommel e DependencyInjection. +- PDBs presentes em `.snupkg` para pacotes runtime. +- PDBs presentes no `.nupkg` de Analyzer e Generator ao lado das DLLs Roslyn. +- `sourcelink print-json` encontrou mapeamento GitHub em todos os PDBs. +- `sourcelink test` completo nao foi usado como gate porque o commit local ainda + nao estava publicado no remoto; a validacao completa de download/checksum deve + rodar em CI apos push. + +## Package Contents + +Pacotes inspecionados em `artifacts/packages-12.4-final`: + +- `Dapper.FluentMap.2.0.0.nupkg`: `README.md`, + `lib/netstandard2.0/Dapper.FluentMap.dll`, XML docs. +- `Dapper.FluentMap.Dommel.2.0.0.nupkg`: `README.md`, + `lib/netstandard2.0/Dapper.FluentMap.Dommel.dll`, XML docs. +- `Dapper.FluentMap.DependencyInjection.2.0.0.nupkg`: `README.md`, + `lib/netstandard2.0/Dapper.FluentMap.DependencyInjection.dll`, XML docs. +- `Dapper.FluentMap.Analyzers.2.0.0.nupkg`: `README.md`, + `analyzers/dotnet/cs/Dapper.FluentMap.Analyzers.dll` e PDB. +- `Dapper.FluentMap.Generators.2.0.0.nupkg`: `README.md`, + `analyzers/dotnet/cs/Dapper.FluentMap.Generators.dll` e PDB. + +Ausencias confirmadas: + +- Sem binarios de teste. +- Sem `.sdd`. +- Sem artifacts internos. +- Sem arquivos temporarios. +- Sem secrets observados no conteudo do pacote. + +## Analyzer And Generator Layout + +- Assemblies Roslyn ficam em `analyzers/dotnet/cs`. +- `IncludeBuildOutput=false` impede `lib/` acidental. +- `SuppressDependenciesWhenPacking=true` impede dependencias Roslyn transitivas + no nuspec. +- `Microsoft.CodeAnalysis.CSharp` e `Microsoft.CodeAnalysis.Analyzers` seguem + com `PrivateAssets=all`. +- Consumers nao recebem Roslyn como dependencia runtime dos pacotes Analyzer e + Generator. + +## Strong Naming + +Estado atual: + +- Assemblies nao possuem public key token. +- Pacotes historicos locais tambem devem ser tratados como linha sem strong-name + ate prova contraria por baseline formal. + +Decisao: + +- Nao adicionar strong naming neste prompt. + +Racional: + +- Strong naming altera identidade de assembly e pode ser breaking para + consumidores. +- Nao ha requisito de GAC, binding policy ou ecossistema corporativo concreto + neste prompt. +- Se necessario futuramente, deve ser decisao de release/major separada, com + chave, assinatura, verificacao e estrategia de migracao. + +## Package Signing + +Estado atual: + +- `dotnet nuget verify artifacts/packages-12.4-final/*.nupkg` confirma hashes, mas + falha com `NU3004` porque os pacotes nao estao assinados. + +Decisao: + +- Nao assinar pacotes neste prompt. +- Package signing deve ser tratado como release engineering separado, com + certificado, owner do segredo, rotação e CI seguro. + +## Release Blockers After This Review + +- Critical: estrategia de versao ainda precisa mudar antes de publicar, porque + `2.0.0` ja existe para core/Dommel. +- Critical: baseline de API do fork ainda precisa ser estabelecida apos o + primeiro RC/versao aprovada. +- High: SourceLink URL/checksum precisa ser testado em CI apos push do commit. +- Medium: package signing segue ausente por decisao consciente. +- Medium: analyzer/generator release manifests ainda precisam revisao antes de + stable. diff --git a/.sdd/etapa-12/DECISIONS.md b/.sdd/etapa-12/DECISIONS.md index ad3e496..19b96e4 100644 --- a/.sdd/etapa-12/DECISIONS.md +++ b/.sdd/etapa-12/DECISIONS.md @@ -337,3 +337,91 @@ SQL Server/PostgreSQL ficam prontos para validacao assim que connection strings ou service containers dedicados forem configurados. A documentacao deve usar `Validated`, `Partial`, `Not validated` e `Unsupported upstream` de forma explicita. + +## ADR-15 - Package metadata, SourceLink and symbols + +### Contexto + +Core e Dommel ainda usavam `PackageLicenseUrl`, nao incluiam README no pacote e +apontavam `PackageProjectUrl` para o repositorio upstream original. Nenhum +pacote gerava `.snupkg` e nao havia propriedades explicitas de repository +metadata, SourceLink, determinismo ou CI build. + +### Decisao + +Centralizar metadata moderna em `Directory.Build.props`: repository URL do +fork, project URL, license expression MIT, README, SourceLink/repository +metadata, determinismo e symbol packages. Analyzer e generator nao geram +`.snupkg`, porque o layout correto desses pacotes e `analyzers/dotnet/cs`, sem +`lib/`; seus PDBs sao empacotados ao lado das DLLs no pacote principal. + +### Alternativas consideradas + +- Manter metadata upstream original: rejeitado porque o fork precisa apontar + para o repositorio que contem o codigo publicado. +- Referenciar pacote SourceLink explicitamente: rejeitado porque o SDK 10 ja + inclui suporte SourceLink para GitHub; adicionar pacote seria redundante. +- Gerar `.snupkg` para analyzer/generator: rejeitado porque o SDK produziu + symbol package vazio e falhou com `NU5017`. + +### Consequencias + +Pacotes runtime passam a ter `.snupkg`; todos os pacotes possuem README, +license expression e repository URL/commit. A validacao completa de URL/checksum +do SourceLink deve rodar apos o commit estar disponivel no remoto. + +## ADR-16 - API/package validation baseline + +### Contexto + +O SDK oferece package validation nativa no `Pack`. O fork ja divergiu bastante +do pacote original `2.0.0`, com APIs novas e uma quebra confirmada em Dommel +(`GeneratedOption` nullable). Uma baseline obrigatoria contra o pacote original +nao representa um gate verde para a linha atual. + +### Decisao + +Habilitar `EnablePackageValidation=true` nos pacotes com `lib/`: core, Dommel e +DependencyInjection. Nao adicionar `PublicApiAnalyzers` neste prompt. A baseline +historica contra `2.0.0` original fica documentada no public API review e a +baseline obrigatoria do fork deve ser definida contra o primeiro RC/versao +aprovada do proprio fork. + +### Alternativas consideradas + +- Adicionar `PublicApiAnalyzers` agora: rejeitado por redundancia e por criar um + baseline textual grande antes da decisao de versao. +- Bloquear pack contra `2.0.0` original: rejeitado porque registraria quebras + ja acumuladas do fork, nao apenas mudancas acidentais futuras. +- Nao habilitar tooling: rejeitado porque package validation sem baseline ja + protege consistencia de pacote e prepara o gate para uma baseline futura. + +### Consequencias + +`dotnet pack` passa a executar package validation nativa para pacotes runtime. +A release ainda precisa de baseline do fork antes de stable. + +## ADR-17 - Strong naming and package signing + +### Contexto + +Assemblies atuais nao possuem public key token. O prompt pediu revisar a +necessidade historica de strong naming e validar pacotes. `dotnet nuget verify` +confirmou hashes, mas falhou com `NU3004` porque os pacotes nao estao assinados. + +### Decisao + +Nao adicionar strong naming e nao assinar pacotes neste prompt. + +### Alternativas consideradas + +- Assinar assemblies strong-name por tradicao: rejeitado porque muda identidade + de assembly e pode ser breaking. +- Assinar `.nupkg` sem processo de release/certificado definido: rejeitado por + risco operacional e segredo ausente. + +### Consequencias + +Strong naming e package signing permanecem decisoes de release engineering +separadas. Se forem adotados futuramente, devem ter chave/certificado, +validacao, ownership e estrategia de migracao. diff --git a/.sdd/etapa-12/STATUS.md b/.sdd/etapa-12/STATUS.md index dcdfd28..a174261 100644 --- a/.sdd/etapa-12/STATUS.md +++ b/.sdd/etapa-12/STATUS.md @@ -50,6 +50,23 @@ SourceLink/reproducibilidade e CI de release. - Adicionado harness condicional para PostgreSQL via `DFM_POSTGRESQL_CONNECTION_STRING`. - Adicionada etapa de provider compatibility ao job `compatibility` da CI. +- Criado `05-public-api-review.md`. +- Examinada API publica atual dos cinco pacotes. +- Comparada API atual de core/Dommel com pacotes historicos NuGet.org `2.0.0`. +- Identificada quebra historica Dommel: + `DommelPropertyMap.GeneratedOption` mudou de `DatabaseGeneratedOption` para + `DatabaseGeneratedOption?`. +- Habilitada package validation nativa do SDK para pacotes runtime com `lib/`. +- Adicionada metadata NuGet moderna comum: repository URL do fork, project URL, + license expression, README, repository commit e SourceLink metadata. +- Removido `PackageLicenseUrl` legado de core/Dommel. +- Adicionados README aos pacotes core/Dommel. +- Gerados `.snupkg` para core, Dommel e DependencyInjection. +- Empacotados PDBs em analyzer/generator no layout `analyzers/dotnet/cs`. +- Confirmado que analyzer/generator nao expoem dependencias Roslyn transitivas + no nuspec. +- Revisada decisao de strong naming: nao adicionar neste prompt. +- Revisada package signing: nao assinar neste prompt. ## Em andamento @@ -57,37 +74,39 @@ SourceLink/reproducibilidade e CI de release. ## Proximos passos -1. Adicionar baseline e tooling de API/binary compatibility. -2. Endurecer metadata de pacote, README de pacote, repository metadata, - SourceLink, symbols e package validation. -3. Documentar migration guide, support policy e provider certification. +1. Definir baseline de API do proprio fork apos primeiro RC/versao aprovada. +2. Documentar migration guide, support policy e provider certification. +3. Validar SourceLink URL/checksum em CI apos push. 4. Definir e validar release candidate antes de stable. 5. Fazer auditoria final de release blockers. ## Release blockers -- Critical: nao ha validacao formal de API/binary compatibility. - Critical: `2.0.0` ja existe no NuGet.org para core e Dommel; a estrategia de versionamento do fork precisa mudar antes de publicar. -- High: core e Dommel geram `NU5125` e aviso de README ausente no pack. -- High: NuGet metadata ainda aponta para o repositorio upstream original. -- High: nao ha SourceLink, repository metadata, symbols ou deterministic CI - policy. +- Critical: baseline de API do proprio fork ainda precisa ser estabelecida apos + o primeiro RC/versao aprovada. +- High: SourceLink URL/checksum precisa ser validado em CI apos push do commit. - High: CI ainda nao valida SQL Server/PostgreSQL com servicos reais nem smokes trimming/AOT. - High: Dapper TypeHandler interoperability depende de internal shape por reflection. - Medium: nao ha `global.json`, package lock ou Central Package Management. - Medium: analyzer/generator release manifests precisam revisao para release. +- Medium: packages nao estao assinados; `dotnet nuget verify` falha com + `NU3004` por ausencia de assinatura. ## Compatibility decisions - Manter `netstandard2.0` como TFM minimo dos pacotes publicos. - Tratar `Dapper [2.1.79,3.0.0)` como faixa suportada atual, com `2.1.79` validado como minimo e latest stable no Prompt 12.2. -- Tratar `Dommel >= 3.5.3` como minimo atual do pacote Dommel. +- Tratar `Dommel [3.5.3,4.0.0)` como faixa atual do pacote Dommel. - Separar provider support de provider certification. - Exigir API/binary compatibility formal antes de stable. +- Usar package validation nativa do SDK nos pacotes runtime. +- Estabelecer baseline obrigatoria contra a linha do proprio fork, nao contra + `2.0.0` original, salvo auditoria historica explicita. - Nao declarar Native AOT completo no estado atual. - Usar RC antes de stable; recomendacao inicial `3.0.0-rc.1`, salvo prova formal que permita `2.1.0-rc.1`. @@ -124,6 +143,8 @@ SourceLink/reproducibilidade e CI de release. - `.sdd/etapa-12/01-release-readiness-audit.md` - `.sdd/etapa-12/02-compatibility-spec.md` - `.sdd/etapa-12/03-compatibility-matrix.md` +- `.sdd/etapa-12/04-provider-matrix.md` +- `.sdd/etapa-12/05-public-api-review.md` - `.sdd/etapa-12/DECISIONS.md` - `.sdd/etapa-12/STATUS.md` - `README.md` @@ -197,7 +218,7 @@ Resultados: ## Ultimo prompt executado -Ultimo prompt executado: 12.3 +Ultimo prompt executado: 12.4 ## Validacao do Prompt 12.3 @@ -238,3 +259,49 @@ Status por provider: | PostgreSQL | `Not validated` | Harness condicional existe, mas nao foi executado contra servico real. | | MySQL/MariaDB | `Not validated` | Nao ha harness obrigatorio neste prompt. | | SQL Server CE | `Unsupported upstream` | Builder legado permanece, sem lane moderna de validacao. | + +## Validacao do Prompt 12.4 + +Executada localmente em 2026-07-29: + +```bash +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-12.4-final +sourcelink print-json +dotnet nuget verify artifacts/packages-12.4-final/*.nupkg +``` + +Resultados: + +- Restore: sucesso. +- Build Release: sucesso, 0 warnings, 0 errors. +- Primeiro pack apos `IncludeSymbols=true` global falhou para Analyzer e + Generator com `NU5017`, porque o SDK tentou criar `.snupkg` vazio para + pacotes sem `lib/`. Corrigido desabilitando `.snupkg` nesses pacotes Roslyn e + empacotando PDBs em `analyzers/dotnet/cs`. +- Test solution: sucesso; 460 aprovados, 14 ignored/skipped, 0 falhas. +- Pack final em `artifacts/packages-12.4-final`: sucesso, 0 warnings, 0 errors. +- Package validation nativa executou durante pack para core, Dommel e + DependencyInjection. +- Pacotes gerados: + - `Dapper.FluentMap.2.0.0.nupkg` e `.snupkg`; + - `Dapper.FluentMap.Dommel.2.0.0.nupkg` e `.snupkg`; + - `Dapper.FluentMap.DependencyInjection.2.0.0.nupkg` e `.snupkg`; + - `Dapper.FluentMap.Analyzers.2.0.0.nupkg`; + - `Dapper.FluentMap.Generators.2.0.0.nupkg`. +- Conteudo dos pacotes inspecionado: + - runtime packages contem `README.md`, assembly `lib/netstandard2.0` e XML + docs; + - analyzer/generator contem `README.md`, DLL e PDB em `analyzers/dotnet/cs`; + - sem binarios de teste, `.sdd`, artifacts internos, temporarios ou secrets. +- Nuspecs contem license expression MIT, project URL/repository URL do fork, + branch/commit e dependencias esperadas. +- SourceLink JSON encontrado nos PDBs dos cinco pacotes apontando para + `raw.githubusercontent.com/rodri-oliveira-dev/Dapper-FluentMap//*`. + Download/checksum nao foi usado como gate local porque o commit ainda nao + estava publicado no remoto. +- `dotnet nuget verify` nos pacotes finais confirmou hashes, mas falhou com + `NU3004` porque os pacotes nao estao assinados. Isso permanece decisao + documentada, nao falha de build/pack. diff --git a/Directory.Build.props b/Directory.Build.props index 809224f..e06284e 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -3,5 +3,21 @@ 2.1.79 2.1.79 [$(DapperMinimumSupportedVersion),3.0.0) + [3.5.3,4.0.0) + + + + git + https://github.com/rodri-oliveira-dev/Dapper-FluentMap + $(RepositoryUrl) + MIT + README.md + true + true + true + true + portable + true + snupkg diff --git a/src/Dapper.FluentMap.Analyzers/Dapper.FluentMap.Analyzers.csproj b/src/Dapper.FluentMap.Analyzers/Dapper.FluentMap.Analyzers.csproj index 127a392..d68cc3b 100644 --- a/src/Dapper.FluentMap.Analyzers/Dapper.FluentMap.Analyzers.csproj +++ b/src/Dapper.FluentMap.Analyzers/Dapper.FluentMap.Analyzers.csproj @@ -8,10 +8,8 @@ false Dapper.FluentMap.Analyzers c#;dapper;mapping;fluentmap;roslyn;analyzers - https://github.com/henkmollema/Dapper-FluentMap - MIT - README.md true + false @@ -19,6 +17,7 @@ + diff --git a/src/Dapper.FluentMap.DependencyInjection/Dapper.FluentMap.DependencyInjection.csproj b/src/Dapper.FluentMap.DependencyInjection/Dapper.FluentMap.DependencyInjection.csproj index 7f1acb1..9d1dcd8 100644 --- a/src/Dapper.FluentMap.DependencyInjection/Dapper.FluentMap.DependencyInjection.csproj +++ b/src/Dapper.FluentMap.DependencyInjection/Dapper.FluentMap.DependencyInjection.csproj @@ -9,9 +9,7 @@ true Dapper.FluentMap.DependencyInjection c#;dapper;mapping;fluentmap;dependency-injection - https://github.com/henkmollema/Dapper-FluentMap - MIT - README.md + true diff --git a/src/Dapper.FluentMap.Dommel/Dapper.FluentMap.Dommel.csproj b/src/Dapper.FluentMap.Dommel/Dapper.FluentMap.Dommel.csproj index 1bb5732..b6ec983 100644 --- a/src/Dapper.FluentMap.Dommel/Dapper.FluentMap.Dommel.csproj +++ b/src/Dapper.FluentMap.Dommel/Dapper.FluentMap.Dommel.csproj @@ -4,15 +4,18 @@ Copyright © Henk Mollema 2014 2.0.0 Henk Mollema - netstandard2.0 + netstandard2.0 true dapper;fluentmap;dommel - https://github.com/henkmollema/Dapper-FluentMap - https://github.com/henkmollema/Dapper-FluentMap/blob/master/LICENSE + true + 2.0.0 - + + + + diff --git a/src/Dapper.FluentMap.Generators/Dapper.FluentMap.Generators.csproj b/src/Dapper.FluentMap.Generators/Dapper.FluentMap.Generators.csproj index 6f8bb5e..0aeabec 100644 --- a/src/Dapper.FluentMap.Generators/Dapper.FluentMap.Generators.csproj +++ b/src/Dapper.FluentMap.Generators/Dapper.FluentMap.Generators.csproj @@ -8,10 +8,8 @@ false Dapper.FluentMap.Generators c#;dapper;mapping;fluentmap;roslyn;source-generator - https://github.com/henkmollema/Dapper-FluentMap - MIT - README.md true + false @@ -19,6 +17,7 @@ + diff --git a/src/Dapper.FluentMap/Dapper.FluentMap.csproj b/src/Dapper.FluentMap/Dapper.FluentMap.csproj index 375df4c..996c0aa 100644 --- a/src/Dapper.FluentMap/Dapper.FluentMap.csproj +++ b/src/Dapper.FluentMap/Dapper.FluentMap.csproj @@ -4,15 +4,18 @@ Copyright © Henk Mollema 2014 2.0.0 Henk Mollema - netstandard2.0 + netstandard2.0 8.0 true c#;dapper;mapping;fluentmap - https://github.com/henkmollema/Dapper-FluentMap - https://github.com/henkmollema/Dapper-FluentMap/blob/master/LICENSE + true + 2.0.0 + + + From 77ee77ab3864eb0f68e309d5225cbe3062cf2aff Mon Sep 17 00:00:00 2001 From: Rodrigo de Oliveira Date: Wed, 29 Jul 2026 12:15:23 -0300 Subject: [PATCH 40/49] ci(release): harden build and release workflows --- .github/dependabot.yml | 24 ++ .github/workflows/ci.yml | 112 ++++++-- .github/workflows/release.yml | 193 ++++++++++++++ .sdd/etapa-12/06-ci-release-design.md | 251 ++++++++++++++++++ .sdd/etapa-12/DECISIONS.md | 60 +++++ .sdd/etapa-12/STATUS.md | 81 +++++- Directory.Build.props | 9 + global.json | 6 + .../Dapper.FluentMap.Analyzers.csproj | 2 +- ...apper.FluentMap.DependencyInjection.csproj | 2 +- .../Dapper.FluentMap.Dommel.csproj | 2 +- .../Dapper.FluentMap.Generators.csproj | 2 +- src/Dapper.FluentMap/Dapper.FluentMap.csproj | 2 +- 13 files changed, 719 insertions(+), 27 deletions(-) create mode 100644 .github/dependabot.yml create mode 100644 .github/workflows/release.yml create mode 100644 .sdd/etapa-12/06-ci-release-design.md create mode 100644 global.json diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..3707a82 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,24 @@ +version: 2 +updates: + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "weekly" + day: "monday" + time: "09:00" + timezone: "America/Sao_Paulo" + open-pull-requests-limit: 5 + + - package-ecosystem: "nuget" + directory: "/" + schedule: + interval: "weekly" + day: "monday" + time: "09:30" + timezone: "America/Sao_Paulo" + open-pull-requests-limit: 5 + groups: + dotnet-minor-patch: + update-types: + - "minor" + - "patch" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6876a6f..5342d46 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -4,7 +4,7 @@ on: push: branches: - master - - chore/net10-migration + - feature/etapa-3 pull_request: env: @@ -15,27 +15,34 @@ env: permissions: contents: read +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + jobs: compatibility: name: Compatibility (${{ matrix.dapper-lane }}, Dapper ${{ matrix.dapper-version }}) runs-on: ubuntu-latest + timeout-minutes: 30 strategy: fail-fast: false matrix: include: - dapper-lane: minimum-and-latest-stable dapper-version: 2.1.79 - dotnet-version: 10.0.x steps: - name: Checkout - uses: actions/checkout@v7 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + fetch-depth: 1 + persist-credentials: false + show-progress: false - name: Setup .NET - uses: actions/setup-dotnet@v6 + uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6 with: - dotnet-version: ${{ matrix.dotnet-version }} - dotnet-quality: ga + global-json-file: global.json - name: Show .NET info run: dotnet --info @@ -43,6 +50,9 @@ jobs: - name: Restore run: dotnet restore ./Dapper.FluentMap.sln -p:DapperPackageVersion=${{ matrix.dapper-version }} + - name: Audit NuGet dependencies + run: dotnet list ./Dapper.FluentMap.sln package --vulnerable --include-transitive + - name: Build Release run: dotnet build ./Dapper.FluentMap.sln --configuration Release --no-restore -p:DapperPackageVersion=${{ matrix.dapper-version }} @@ -64,20 +74,27 @@ jobs: roslyn-components: name: Analyzer and generator compatibility runs-on: ubuntu-latest + timeout-minutes: 20 steps: - name: Checkout - uses: actions/checkout@v7 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + fetch-depth: 1 + persist-credentials: false + show-progress: false - name: Setup .NET - uses: actions/setup-dotnet@v6 + uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6 with: - dotnet-version: 10.0.x - dotnet-quality: ga + global-json-file: global.json - name: Restore run: dotnet restore ./Dapper.FluentMap.sln + - name: Audit NuGet dependencies + run: dotnet list ./Dapper.FluentMap.sln package --vulnerable --include-transitive + - name: Build analyzers run: dotnet build ./src/Dapper.FluentMap.Analyzers/Dapper.FluentMap.Analyzers.csproj --configuration Release --no-restore @@ -93,19 +110,23 @@ jobs: pack: name: Pack runs-on: ubuntu-latest + timeout-minutes: 20 needs: - compatibility - roslyn-components steps: - name: Checkout - uses: actions/checkout@v7 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + fetch-depth: 1 + persist-credentials: false + show-progress: false - name: Setup .NET - uses: actions/setup-dotnet@v6 + uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6 with: - dotnet-version: 10.0.x - dotnet-quality: ga + global-json-file: global.json - name: Restore run: dotnet restore ./Dapper.FluentMap.sln @@ -116,9 +137,68 @@ jobs: - name: Pack Release run: dotnet pack ./Dapper.FluentMap.sln --configuration Release --no-build --output ./artifacts/packages + - name: Validate package artifact set + shell: pwsh + run: | + $ErrorActionPreference = 'Stop' + $packageDir = './artifacts/packages' + $nupkgs = @(Get-ChildItem -Path $packageDir -Filter '*.nupkg' -File) + $snupkgs = @(Get-ChildItem -Path $packageDir -Filter '*.snupkg' -File) + $unexpected = @($nupkgs + $snupkgs | Where-Object { $_.Name -match '(Tests|Benchmarks|AotSmoke)' }) + + if ($nupkgs.Count -ne 5) { + throw "Expected 5 .nupkg files, found $($nupkgs.Count)." + } + + if ($snupkgs.Count -ne 3) { + throw "Expected 3 .snupkg files, found $($snupkgs.Count)." + } + + if ($unexpected.Count -gt 0) { + throw "Unexpected test/benchmark artifacts: $($unexpected.Name -join ', ')" + } + + - name: Write release metadata + shell: pwsh + run: | + $ErrorActionPreference = 'Stop' + New-Item -ItemType Directory -Force -Path './artifacts/release-metadata' | Out-Null + dotnet list ./Dapper.FluentMap.sln package --include-transitive --format json | + Set-Content -Encoding utf8NoBOM -Path './artifacts/release-metadata/dependencies.json' + + $packageFiles = @( + Get-ChildItem -Path './artifacts/packages' -Filter '*.nupkg' -File + Get-ChildItem -Path './artifacts/packages' -Filter '*.snupkg' -File + ) | + Sort-Object Name | + ForEach-Object { + [ordered]@{ + name = $_.Name + sha256 = (Get-FileHash -LiteralPath $_.FullName -Algorithm SHA256).Hash.ToLowerInvariant() + size = $_.Length + } + } + + [ordered]@{ + schemaVersion = '1.0' + repository = '${{ github.repository }}' + ref = '${{ github.ref }}' + sha = '${{ github.sha }}' + runId = '${{ github.run_id }}' + runAttempt = '${{ github.run_attempt }}' + dotnetSdk = (dotnet --version) + packageFiles = @($packageFiles) + } | + ConvertTo-Json -Depth 5 | + Set-Content -Encoding utf8NoBOM -Path './artifacts/release-metadata/release-metadata.json' + - name: Upload NuGet packages - uses: actions/upload-artifact@v7 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: name: nuget-packages - path: ./artifacts/packages/*.nupkg + path: | + ./artifacts/packages/*.nupkg + ./artifacts/packages/*.snupkg + ./artifacts/release-metadata/*.json if-no-files-found: error + retention-days: 14 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..d427da2 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,193 @@ +name: Release + +on: + workflow_dispatch: + inputs: + package-version: + description: "NuGet package version to validate and pack, for example 3.0.0-rc.1." + required: true + default: "3.0.0-rc.1" + publish: + description: "Reserved for a future approval-based NuGet publish flow. Publishing is disabled in this workflow." + required: true + type: boolean + default: false + +env: + DOTNET_CLI_TELEMETRY_OPTOUT: "true" + DOTNET_SKIP_FIRST_TIME_EXPERIENCE: "true" + CI: "true" + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ inputs.package-version }} + cancel-in-progress: false + +jobs: + validate-package: + name: Validate release package + runs-on: ubuntu-latest + timeout-minutes: 45 + + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + fetch-depth: 1 + persist-credentials: false + show-progress: false + + - name: Setup .NET + uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6 + with: + global-json-file: global.json + + - name: Validate release input + shell: pwsh + run: | + $ErrorActionPreference = 'Stop' + $version = '${{ inputs.package-version }}' + + if ($version -notmatch '^\d+\.\d+\.\d+(-[0-9A-Za-z][0-9A-Za-z.-]*)?$') { + throw "Package version '$version' is not a supported SemVer value." + } + + if ($version -eq '2.0.0') { + throw "Version 2.0.0 already exists for historical package IDs and must not be used by this fork." + } + + - name: Guard disabled publish path + if: ${{ inputs.publish }} + shell: pwsh + run: | + $ErrorActionPreference = 'Stop' + throw "NuGet publishing is intentionally disabled. Configure NuGet trusted publishing/OIDC and an approval environment before enabling publish." + + - name: Show .NET info + run: dotnet --info + + - name: Restore + run: dotnet restore ./Dapper.FluentMap.sln + + - name: Audit NuGet dependencies + run: dotnet list ./Dapper.FluentMap.sln package --vulnerable --include-transitive + + - name: Build Release + run: dotnet build ./Dapper.FluentMap.sln --configuration Release --no-restore -p:VersionPrefix=${{ inputs.package-version }} + + - name: Test Release + run: dotnet test ./Dapper.FluentMap.sln --configuration Release --no-build + + - name: Pack Release + run: dotnet pack ./Dapper.FluentMap.sln --configuration Release --no-build --output ./artifacts/packages -p:VersionPrefix=${{ inputs.package-version }} + + - name: Validate package artifact set + shell: pwsh + run: | + $ErrorActionPreference = 'Stop' + $packageDir = './artifacts/packages' + $version = '${{ inputs.package-version }}' + $expectedNupkgs = @( + "Dapper.FluentMap.$version.nupkg", + "Dapper.FluentMap.Dommel.$version.nupkg", + "Dapper.FluentMap.DependencyInjection.$version.nupkg", + "Dapper.FluentMap.Analyzers.$version.nupkg", + "Dapper.FluentMap.Generators.$version.nupkg" + ) + $expectedSnupkgs = @( + "Dapper.FluentMap.$version.snupkg", + "Dapper.FluentMap.Dommel.$version.snupkg", + "Dapper.FluentMap.DependencyInjection.$version.snupkg" + ) + $actualNupkgs = @(Get-ChildItem -Path $packageDir -Filter '*.nupkg' -File | Select-Object -ExpandProperty Name) + $actualSnupkgs = @(Get-ChildItem -Path $packageDir -Filter '*.snupkg' -File | Select-Object -ExpandProperty Name) + $missing = @($expectedNupkgs + $expectedSnupkgs | Where-Object { $_ -notin @($actualNupkgs + $actualSnupkgs) }) + $unexpected = @($actualNupkgs + $actualSnupkgs | Where-Object { $_ -notin @($expectedNupkgs + $expectedSnupkgs) }) + + if ($missing.Count -gt 0) { + throw "Missing package artifacts: $($missing -join ', ')" + } + + if ($unexpected.Count -gt 0) { + throw "Unexpected package artifacts: $($unexpected -join ', ')" + } + + - name: Write release metadata + shell: pwsh + run: | + $ErrorActionPreference = 'Stop' + New-Item -ItemType Directory -Force -Path './artifacts/release-metadata' | Out-Null + dotnet list ./Dapper.FluentMap.sln package --include-transitive --format json | + Set-Content -Encoding utf8NoBOM -Path './artifacts/release-metadata/dependencies.json' + + $packageFiles = @( + Get-ChildItem -Path './artifacts/packages' -Filter '*.nupkg' -File + Get-ChildItem -Path './artifacts/packages' -Filter '*.snupkg' -File + ) | + Sort-Object Name | + ForEach-Object { + [ordered]@{ + name = $_.Name + sha256 = (Get-FileHash -LiteralPath $_.FullName -Algorithm SHA256).Hash.ToLowerInvariant() + size = $_.Length + } + } + + [ordered]@{ + schemaVersion = '1.0' + repository = '${{ github.repository }}' + ref = '${{ github.ref }}' + sha = '${{ github.sha }}' + runId = '${{ github.run_id }}' + runAttempt = '${{ github.run_attempt }}' + packageVersion = '${{ inputs.package-version }}' + dotnetSdk = (dotnet --version) + packageFiles = @($packageFiles) + } | + ConvertTo-Json -Depth 5 | + Set-Content -Encoding utf8NoBOM -Path './artifacts/release-metadata/release-metadata.json' + + - name: Upload release package artifacts + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: release-packages-${{ inputs.package-version }} + path: | + ./artifacts/packages/*.nupkg + ./artifacts/packages/*.snupkg + ./artifacts/release-metadata/*.json + if-no-files-found: error + retention-days: 90 + + provenance: + name: Attest release package provenance + runs-on: ubuntu-latest + needs: validate-package + timeout-minutes: 10 + permissions: + contents: read + id-token: write + attestations: write + + steps: + - name: Download release package artifacts + uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6 + with: + name: release-packages-${{ inputs.package-version }} + path: ./artifacts/release + + - name: Collect package subjects + shell: pwsh + run: | + $ErrorActionPreference = 'Stop' + New-Item -ItemType Directory -Force -Path './artifacts/attestation-subjects' | Out-Null + Get-ChildItem -Path './artifacts/release' -Recurse -File -Include '*.nupkg', '*.snupkg' | + Copy-Item -Destination './artifacts/attestation-subjects' + + - name: Attest package provenance + uses: actions/attest-build-provenance@43d14bc2b83dec42d39ecae14e916627a18bb661 # v3 + with: + subject-path: | + ./artifacts/attestation-subjects/*.nupkg + ./artifacts/attestation-subjects/*.snupkg diff --git a/.sdd/etapa-12/06-ci-release-design.md b/.sdd/etapa-12/06-ci-release-design.md new file mode 100644 index 0000000..74bb038 --- /dev/null +++ b/.sdd/etapa-12/06-ci-release-design.md @@ -0,0 +1,251 @@ +# CI and Release Design + +Documento criado em 2026-07-29 para o Prompt 12.5. + +## Pipeline Model + +```text +Pull Request + ↓ +Restore +Build +Tests +Compatibility +Package validation + +Main + ↓ +Full validation +Provider tests +Pack artifacts + +Release + ↓ +Validation +Packages +Provenance +Publish +``` + +Publish permanece desabilitado neste prompt. O workflow de release prepara +validacao, packages, metadata e provenance; a publicacao NuGet deve ser +habilitada somente depois de configurar trusted publishing/OIDC no NuGet.org e +um ambiente de aprovacao no GitHub. + +## Workflows + +### Pull Request / Main: `.github/workflows/ci.yml` + +Triggers: + +- `pull_request`; +- `push` para `master`; +- `push` para `feature/etapa-3`, enquanto a etapa de release engineering roda + nessa branch de trabalho. + +Jobs: + +- `compatibility`: restore, audit, build Release e testes runtime contra a lane + Dapper `2.1.79`, que hoje e minimo e latest stable aprovados. +- `roslyn-components`: build/test separado para analyzers e generators. +- `pack`: pack unico da solution, validacao do conjunto de artefatos e upload + de `.nupkg`, `.snupkg` e metadata. + +### Release: `.github/workflows/release.yml` + +Trigger: + +- `workflow_dispatch` manual com `package-version`, default + `3.0.0-rc.1`. + +Fluxo: + +1. valida SemVer simples; +2. rejeita `2.0.0`, porque esse numero ja existe nos package IDs historicos; +3. restaura; +4. audita dependencias; +5. compila Release; +6. executa testes da solution; +7. empacota com `-p:VersionPrefix=`; +8. valida o conjunto esperado de artefatos; +9. gera metadata de release; +10. faz upload dos artefatos; +11. gera artifact attestations de provenance para `.nupkg` e `.snupkg`. + +O input `publish` existe apenas como guarda explicita: se marcado, o workflow +falha com mensagem indicando que publish ainda nao esta habilitado. Isso evita +publicacao acidental antes dos gates de release. + +## CI Hardening + +Medidas aplicadas: + +- `permissions: contents: read` como base. +- Elevacao de permissao somente no job de provenance: + `id-token: write` e `attestations: write`. +- Actions pinadas por SHA completo, com comentario da tag revisada: + - `actions/checkout` `v7`; + - `actions/setup-dotnet` `v6`; + - `actions/upload-artifact` `v7`; + - `actions/download-artifact` `v6`; + - `actions/attest-build-provenance` `v3`. +- `actions/checkout` usa `persist-credentials: false`, `fetch-depth: 1` e + `show-progress: false`. +- Workflows usam `concurrency` para evitar runs redundantes no CI e evitar dois + releases simultaneos da mesma versao. +- Jobs possuem `timeout-minutes`. +- Artifact retention explicito: + - CI: 14 dias; + - release: 90 dias. + +Fork PR safety: + +- O CI nao usa `pull_request_target`. +- O CI nao le secrets. +- O CI nao publica pacotes. +- O CI roda com token de leitura apenas. +- Provenance e release rodam somente em `workflow_dispatch`, nao em PR. + +Cache: + +- Nenhum cache de NuGet foi adicionado neste prompt. +- Motivo: o repositorio ainda nao usa lock files; cache sem lock aumenta a + chance de comportamento menos auditavel. Cache pode ser adotado junto com + `packages.lock.json` ou politica equivalente. + +## Dependency Security + +Estado observado em codigo: + +- Nao havia Dependabot ou Renovate configurado. +- `NuGet.Config` limpa feeds e usa apenas NuGet.org. +- Nao ha lock files. +- Versoes ainda ficam nos projetos, mas a versao dos pacotes FluentMap foi + centralizada por propriedade compartilhada. + +Medidas aplicadas: + +- Adicionado `.github/dependabot.yml` para GitHub Actions e NuGet. +- Adicionado NuGet Audit em `Directory.Build.props`: + - `NuGetAudit=true`; + - `NuGetAuditMode=all`; + - `NuGetAuditLevel=low`. +- Em CI, warnings `NU1901` a `NU1904` viram erro via `WarningsAsErrors`. +- Workflows executam + `dotnet list ./Dapper.FluentMap.sln package --vulnerable --include-transitive`. + +Politica: + +- Nao fazer upgrades major automaticamente ou junto de release hardening. +- Atualizacoes major devem ter PR proprio, revisao de compatibilidade e matriz + de testes. +- Lock files permanecem decisao futura. Para a matriz Dapper atual, o range + `[2.1.79,3.0.0)` e a lane override por `DapperPackageVersion` sao mais + importantes do que travar tudo sem estrategia de renovacao. + +## Reproducibility + +Medidas aplicadas: + +- Adicionado `global.json` com SDK `10.0.302` e roll-forward limitado ao feature + band. +- Workflows usam `actions/setup-dotnet` apontando para `global.json`. +- O mesmo fluxo semantico e usado localmente e em CI: + `restore` -> `build Release --no-restore` -> `test Release --no-build` -> + `pack Release --no-build`. +- `Directory.Build.props` ja define: + - `Deterministic=true`; + - `ContinuousIntegrationBuild=true` quando `CI=true`; + - SourceLink/repository metadata; + - symbol packages para pacotes runtime. + +Versionamento: + +- A versao `2.0.0` nao foi trocada para RC neste prompt porque a ADR-6 ainda + condiciona a decisao final a baseline de compatibilidade. +- O valor foi centralizado em `FluentMapPackageVersionPrefix` para evitar edicao + manual duplicada em cinco `.csproj`. +- O workflow de release permite validar RC com + `-p:VersionPrefix=` sem alterar arquivos de projeto. + +## Artifacts + +Conjunto esperado: + +- 5 `.nupkg`: + - `Dapper.FluentMap`; + - `Dapper.FluentMap.Dommel`; + - `Dapper.FluentMap.DependencyInjection`; + - `Dapper.FluentMap.Analyzers`; + - `Dapper.FluentMap.Generators`. +- 3 `.snupkg`: + - `Dapper.FluentMap`; + - `Dapper.FluentMap.Dommel`; + - `Dapper.FluentMap.DependencyInjection`. +- release metadata: + - `release-metadata.json` com repo, ref, SHA, run, SDK e SHA-256 dos + pacotes; + - `dependencies.json` com inventario `dotnet list package --include-transitive + --format json`. + +O workflow falha se aparecer pacote de test/benchmark/smoke ou se a contagem +esperada nao bater. + +## SBOM and Provenance + +Capacidades avaliadas: + +- GitHub Artifact Attestations geram provenance com OIDC e nao exigem segredo + persistente. +- NuGet trusted publishing permite trocar OIDC de GitHub Actions por credencial + curta no NuGet.org, evitando API key longa. +- `actions/attest-sbom` atesta SBOM existente, mas nao gera o SBOM. +- Microsoft SBOM Tool e sustentavel, mas adiciona ferramenta externa e politica + operacional propria. + +Decisao deste prompt: + +- Adicionar provenance nativo do GitHub para os pacotes no release workflow. +- Nao adicionar SBOM formal ainda. +- Incluir inventario JSON de dependencias como release metadata, sem chamar isso + de SBOM SPDX/CycloneDX. +- Futuro SBOM deve usar ferramenta aprovada, gerar SPDX ou CycloneDX e entao + opcionalmente usar `actions/attest-sbom`. + +Referencias oficiais consultadas: + +- NuGet trusted publishing: + `https://learn.microsoft.com/en-us/nuget/nuget-org/trusted-publishing` +- NuGet audit: + `https://learn.microsoft.com/en-us/nuget/concepts/auditing-packages` +- GitHub artifact attestations: + `https://docs.github.com/actions/security-for-github-actions/using-artifact-attestations/using-artifact-attestations-to-establish-provenance-for-builds` +- GitHub SBOM attestation action: + `https://github.com/actions/attest-sbom` + +## Release History + +Historico acessivel no checkout local: + +- Tags locais/remotas historicas vao de `v1.0.2` a `v2.0.0`. +- Tag mais recente observada: `v2.0.0`, em 2020-08-23. +- `gh release list --repo rodri-oliveira-dev/Dapper-FluentMap --limit 20` + nao retornou releases publicadas no fork. + +Implicacao: + +- Tags historicas existem, mas o fork ainda precisa de primeiro RC proprio. +- `2.0.0` nao deve ser reutilizada nos package IDs historicos. + +## Publish Requirements + +Antes de habilitar publish: + +1. criar baseline de API do proprio fork; +2. configurar trusted publisher no NuGet.org para o repositorio/workflow; +3. criar ambiente GitHub protegido para aprovacao manual; +4. substituir a guarda `publish` por fluxo OIDC aprovado; +5. validar install/consumer smoke dos pacotes gerados; +6. documentar rollback e criterios de promocao RC -> stable. + +Nenhum segredo NuGet foi adicionado e nenhum publish foi executado. diff --git a/.sdd/etapa-12/DECISIONS.md b/.sdd/etapa-12/DECISIONS.md index 19b96e4..8e80b49 100644 --- a/.sdd/etapa-12/DECISIONS.md +++ b/.sdd/etapa-12/DECISIONS.md @@ -425,3 +425,63 @@ Nao adicionar strong naming e nao assinar pacotes neste prompt. Strong naming e package signing permanecem decisoes de release engineering separadas. Se forem adotados futuramente, devem ter chave/certificado, validacao, ownership e estrategia de migracao. + +## ADR-18 - CI hardening and release workflow + +### Contexto + +O workflow existente ja restaurava, compilava, testava e empacotava, mas nao +possuia release workflow dedicado, provenance, artifact retention explicito, +timeouts, checkout endurecido ou pin por SHA completo. + +### Decisao + +Endurecer o CI com permissoes minimas, actions pinadas por SHA, checkout sem +persistencia de credencial, timeouts, concurrency e artefatos previsiveis. +Adicionar workflow manual de release que valida e empacota uma versao informada, +gera metadata e provenance, mas falha explicitamente caso alguem tente publicar. + +### Alternativas consideradas + +- Publicar diretamente no NuGet por segredo `NUGET_API_KEY`: rejeitado por + segredo longo e ausencia de gates. +- Fazer release automatico em tag neste prompt: rejeitado ate baseline de API, + trusted publishing e ambiente de aprovacao existirem. +- Manter actions apenas por tag: rejeitado para hardening inicial, embora + Dependabot passe a monitorar atualizacoes. + +### Consequencias + +O repositorio passa a ter caminho de release reproduzivel e auditavel, mas +publicacao segue bloqueada por design. A manutencao dos SHAs pinados deve vir +por PRs de Dependabot/revisao humana. + +## ADR-19 - Dependency audit, SDK pin and SBOM boundary + +### Contexto + +Nao havia Dependabot/Renovate, lock files, `global.json` ou SBOM. O NuGet Audit +e as GitHub Artifact Attestations sao capacidades nativas/sustentaveis do +ecossistema atual. + +### Decisao + +Adicionar Dependabot para Actions/NuGet, habilitar NuGet Audit transitive com +severidade `low`, tratar `NU1901`-`NU1904` como erro em CI e fixar o SDK em +`global.json`. Gerar inventario JSON de dependencias e provenance no release +workflow. Nao adicionar SBOM formal neste prompt. + +### Alternativas consideradas + +- Adotar lock files agora: adiado para tarefa propria por impactar restore e + renovacao de dependencias em toda a solution. +- Adotar Microsoft SBOM Tool agora: rejeitado por adicionar ferramenta externa + antes da politica de SBOM estar definida. +- Chamar inventario `dotnet list package` de SBOM: rejeitado porque nao e SPDX + nem CycloneDX. + +### Consequencias + +Dependencias vulneraveis passam a bloquear CI quando reportadas pelo NuGet +Audit. Builds usam SDK previsivel. SBOM permanece requisito futuro, sem claim +indevido neste release hardening. diff --git a/.sdd/etapa-12/STATUS.md b/.sdd/etapa-12/STATUS.md index a174261..6c9511f 100644 --- a/.sdd/etapa-12/STATUS.md +++ b/.sdd/etapa-12/STATUS.md @@ -8,10 +8,11 @@ features. ## Estado geral -Etapa 12 iniciada com auditoria documental e baseline de build/test/pack. A -solution esta buildable e testable no ambiente local, mas ainda nao esta -release-ready por lacunas de API compatibility, versionamento, NuGet metadata, -SourceLink/reproducibilidade e CI de release. +Etapa 12 iniciou com auditoria documental e baseline de build/test/pack. A +solution esta buildable e testable no ambiente local. A automacao de CI/release +foi preparada, mas a release stable ainda permanece bloqueada por baseline de +API, estrategia final de versionamento, SBOM formal, package signing opcional e +publish NuGet ainda desabilitado. ## Concluido @@ -67,6 +68,20 @@ SourceLink/reproducibilidade e CI de release. no nuspec. - Revisada decisao de strong naming: nao adicionar neste prompt. - Revisada package signing: nao assinar neste prompt. +- Criado `06-ci-release-design.md`. +- Adicionado `global.json` com SDK `10.0.302`. +- Centralizado `VersionPrefix` dos pacotes em + `FluentMapPackageVersionPrefix`. +- Habilitado NuGet Audit transitive em `Directory.Build.props`. +- Configurado `NU1901`-`NU1904` como erro em CI. +- Adicionado `.github/dependabot.yml` para GitHub Actions e NuGet. +- Endurecido `.github/workflows/ci.yml` com actions pinadas por SHA, + checkout sem credencial persistida, timeouts, concurrency, artifact retention + e upload de `.nupkg`, `.snupkg` e metadata. +- Criado `.github/workflows/release.yml` manual para validar versao, restaurar, + auditar, compilar, testar, empacotar, validar artefatos, gerar metadata e + gerar provenance. +- Mantida publicacao NuGet desabilitada por design. ## Em andamento @@ -91,7 +106,8 @@ SourceLink/reproducibilidade e CI de release. trimming/AOT. - High: Dapper TypeHandler interoperability depende de internal shape por reflection. -- Medium: nao ha `global.json`, package lock ou Central Package Management. +- Medium: nao ha package lock ou Central Package Management. +- Medium: SBOM formal SPDX/CycloneDX ainda nao foi adotado. - Medium: analyzer/generator release manifests precisam revisao para release. - Medium: packages nao estao assinados; `dotnet nuget verify` falha com `NU3004` por ausencia de assinatura. @@ -110,6 +126,8 @@ SourceLink/reproducibilidade e CI de release. - Nao declarar Native AOT completo no estado atual. - Usar RC antes de stable; recomendacao inicial `3.0.0-rc.1`, salvo prova formal que permita `2.1.0-rc.1`. +- Publish NuGet deve usar trusted publishing/OIDC e ambiente de aprovacao antes + de ser habilitado. ## Packages @@ -145,6 +163,7 @@ SourceLink/reproducibilidade e CI de release. - `.sdd/etapa-12/03-compatibility-matrix.md` - `.sdd/etapa-12/04-provider-matrix.md` - `.sdd/etapa-12/05-public-api-review.md` +- `.sdd/etapa-12/06-ci-release-design.md` - `.sdd/etapa-12/DECISIONS.md` - `.sdd/etapa-12/STATUS.md` - `README.md` @@ -218,7 +237,7 @@ Resultados: ## Ultimo prompt executado -Ultimo prompt executado: 12.4 +Ultimo prompt executado: 12.5 ## Validacao do Prompt 12.3 @@ -305,3 +324,53 @@ Resultados: - `dotnet nuget verify` nos pacotes finais confirmou hashes, mas falhou com `NU3004` porque os pacotes nao estao assinados. Isso permanece decisao documentada, nao falha de build/pack. + +## Validacao do Prompt 12.5 + +Executada localmente em 2026-07-29: + +```bash +python - .github/workflows/ci.yml .github/workflows/release.yml .github/dependabot.yml +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-12.5-final +dotnet list ./Dapper.FluentMap.sln package --vulnerable --include-transitive +dotnet list ./Dapper.FluentMap.sln package --include-transitive --format json +dotnet build ./Dapper.FluentMap.sln --configuration Release --no-restore -p:VersionPrefix=3.0.0-rc.1 +dotnet pack ./Dapper.FluentMap.sln --configuration Release --no-build --output ./artifacts/packages-12.5-rc -p:VersionPrefix=3.0.0-rc.1 +dotnet test ./Dapper.FluentMap.sln --configuration Release --no-build +$env:CI='true'; dotnet restore ./Dapper.FluentMap.sln +git diff --check +``` + +Resultados: + +- YAML de `ci.yml`, `release.yml` e `dependabot.yml` parseado com sucesso via + PyYAML. +- `pwsh` e Ruby nao estavam disponiveis localmente; os scripts PowerShell de + validacao de artefatos foram executados em Windows PowerShell com comandos + equivalentes. O runner GitHub `ubuntu-latest` fornece `pwsh`. +- Restore padrao: sucesso. +- Build Release padrao: sucesso, 0 warnings, 0 errors. +- Test solution apos build padrao: sucesso; 460 aprovados, 14 + ignored/skipped, 0 falhas. +- Pack padrao em `artifacts/packages-12.5-final`: sucesso; gerou 5 `.nupkg` e + 3 `.snupkg`. +- Validacao de artefatos padrao: sucesso; nenhum pacote de test, benchmark ou + AOT smoke foi gerado. +- Vulnerability audit: nenhum pacote vulneravel encontrado nas fontes atuais. +- `dependencies.json` gerado via `dotnet list package --include-transitive + --format json`. +- Build com `-p:VersionPrefix=3.0.0-rc.1`: sucesso, 0 warnings, 0 errors. +- Pack RC em `artifacts/packages-12.5-rc`: sucesso; gerou os 5 `.nupkg` e 3 + `.snupkg` com versao `3.0.0-rc.1`. +- Test solution apos build RC: sucesso; 460 aprovados, 14 ignored/skipped, 0 + falhas. +- Restore com `CI=true`: sucesso, validando a politica de `WarningsAsErrors` + para `NU1901`-`NU1904`. +- `git diff --check`: sem erros; apenas avisos esperados de normalizacao LF -> + CRLF no Windows. +- Todos os `uses:` em workflows estao pinados por SHA completo. +- Provenance nao foi executado localmente porque depende do ambiente GitHub + Actions/OIDC. diff --git a/Directory.Build.props b/Directory.Build.props index e06284e..7357a7b 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -1,11 +1,20 @@ + 2.0.0 2.1.79 2.1.79 [$(DapperMinimumSupportedVersion),3.0.0) [3.5.3,4.0.0) + + true + all + low + NU1901;NU1902;NU1903;NU1904 + $(WarningsAsErrors);NU1901;NU1902;NU1903;NU1904 + + git https://github.com/rodri-oliveira-dev/Dapper-FluentMap diff --git a/global.json b/global.json new file mode 100644 index 0000000..545c92c --- /dev/null +++ b/global.json @@ -0,0 +1,6 @@ +{ + "sdk": { + "version": "10.0.302", + "rollForward": "latestFeature" + } +} diff --git a/src/Dapper.FluentMap.Analyzers/Dapper.FluentMap.Analyzers.csproj b/src/Dapper.FluentMap.Analyzers/Dapper.FluentMap.Analyzers.csproj index d68cc3b..aca71d0 100644 --- a/src/Dapper.FluentMap.Analyzers/Dapper.FluentMap.Analyzers.csproj +++ b/src/Dapper.FluentMap.Analyzers/Dapper.FluentMap.Analyzers.csproj @@ -1,7 +1,7 @@ Roslyn analyzers for Dapper.FluentMap configuration. - 2.0.0 + $(FluentMapPackageVersionPrefix) Henk Mollema netstandard2.0 true diff --git a/src/Dapper.FluentMap.DependencyInjection/Dapper.FluentMap.DependencyInjection.csproj b/src/Dapper.FluentMap.DependencyInjection/Dapper.FluentMap.DependencyInjection.csproj index 9d1dcd8..48dcf8b 100644 --- a/src/Dapper.FluentMap.DependencyInjection/Dapper.FluentMap.DependencyInjection.csproj +++ b/src/Dapper.FluentMap.DependencyInjection/Dapper.FluentMap.DependencyInjection.csproj @@ -2,7 +2,7 @@ Dependency injection integration for Dapper.FluentMap. Copyright © Henk Mollema 2014 - 2.0.0 + $(FluentMapPackageVersionPrefix) Henk Mollema netstandard2.0 8.0 diff --git a/src/Dapper.FluentMap.Dommel/Dapper.FluentMap.Dommel.csproj b/src/Dapper.FluentMap.Dommel/Dapper.FluentMap.Dommel.csproj index b6ec983..fe64b8d 100644 --- a/src/Dapper.FluentMap.Dommel/Dapper.FluentMap.Dommel.csproj +++ b/src/Dapper.FluentMap.Dommel/Dapper.FluentMap.Dommel.csproj @@ -2,7 +2,7 @@ Dapper.FluentMap extension for Dommel support. Copyright © Henk Mollema 2014 - 2.0.0 + $(FluentMapPackageVersionPrefix) Henk Mollema netstandard2.0 true diff --git a/src/Dapper.FluentMap.Generators/Dapper.FluentMap.Generators.csproj b/src/Dapper.FluentMap.Generators/Dapper.FluentMap.Generators.csproj index 0aeabec..2485050 100644 --- a/src/Dapper.FluentMap.Generators/Dapper.FluentMap.Generators.csproj +++ b/src/Dapper.FluentMap.Generators/Dapper.FluentMap.Generators.csproj @@ -1,7 +1,7 @@ Source generators for Dapper.FluentMap mapping registration. - 2.0.0 + $(FluentMapPackageVersionPrefix) Henk Mollema netstandard2.0 true diff --git a/src/Dapper.FluentMap/Dapper.FluentMap.csproj b/src/Dapper.FluentMap/Dapper.FluentMap.csproj index 996c0aa..ca0edb8 100644 --- a/src/Dapper.FluentMap/Dapper.FluentMap.csproj +++ b/src/Dapper.FluentMap/Dapper.FluentMap.csproj @@ -2,7 +2,7 @@ Simple API to fluently map POCO properties to database columns when using Dapper. Copyright © Henk Mollema 2014 - 2.0.0 + $(FluentMapPackageVersionPrefix) Henk Mollema netstandard2.0 8.0 From 432705c118e697d4f51fecede1c1682d3d3f66fc Mon Sep 17 00:00:00 2001 From: Rodrigo de Oliveira Date: Wed, 29 Jul 2026 13:12:17 -0300 Subject: [PATCH 41/49] docs: prepare FluentMap for stable adoption --- .../07-release-candidate-checklist.md | 112 ++ .sdd/etapa-12/STATUS.md | 74 +- CHANGELOG.md | 22 + COMPATIBILITY.md | 123 ++ MIGRATION.md | 282 +++ README.md | 1629 ++++------------- SUPPORT.md | 73 + 7 files changed, 1064 insertions(+), 1251 deletions(-) create mode 100644 .sdd/etapa-12/07-release-candidate-checklist.md create mode 100644 CHANGELOG.md create mode 100644 COMPATIBILITY.md create mode 100644 MIGRATION.md create mode 100644 SUPPORT.md diff --git a/.sdd/etapa-12/07-release-candidate-checklist.md b/.sdd/etapa-12/07-release-candidate-checklist.md new file mode 100644 index 0000000..7b461ad --- /dev/null +++ b/.sdd/etapa-12/07-release-candidate-checklist.md @@ -0,0 +1,112 @@ +# Release Candidate Checklist + +Checklist for promoting the current fork line to a release candidate. Do not create a tag, GitHub release or NuGet publish until the required gates are complete. + +## Build + +- [ ] `dotnet restore ./Dapper.FluentMap.sln` passes. +- [ ] `dotnet build ./Dapper.FluentMap.sln --configuration Release --no-restore` passes. +- [ ] Release build has zero warnings or every warning is explicitly accepted for RC. +- [ ] Local SDK matches `global.json`. + +## Tests + +- [ ] `dotnet test ./Dapper.FluentMap.sln --configuration Release --no-build` passes. +- [ ] Core tests pass. +- [ ] Dommel tests pass. +- [ ] DependencyInjection tests pass. +- [ ] Analyzer tests pass. +- [ ] Generator tests pass. +- [ ] Generated registration tests pass. +- [ ] Provider compatibility tests pass with SQLite. +- [ ] Skipped conditional provider tests are recorded with reason. + +## Compatibility + +- [ ] `netstandard2.0` remains the public package TFM. +- [ ] Test runtime remains documented separately from package TFM. +- [ ] Dapper range is reviewed and matches package metadata. +- [ ] Dommel range is reviewed and matches package metadata. +- [ ] Known Dapper TypeHandler reflection boundary is covered by tests. + +## Providers + +- [ ] SQLite provider tests pass. +- [ ] SQL Server harness status is recorded. +- [ ] PostgreSQL harness status is recorded. +- [ ] MySQL/MariaDB status remains documented as not validated unless real tests are added. +- [ ] SQL Server CE status remains documented as legacy/upstream-limited. + +## Packages + +- [ ] `dotnet pack ./Dapper.FluentMap.sln --configuration Release --no-build --output ./artifacts/packages-rc` passes. +- [ ] Exactly five `.nupkg` files are produced. +- [ ] Exactly three `.snupkg` files are produced for runtime packages. +- [ ] No test, benchmark, smoke, `.sdd`, temporary or secret files are included. +- [ ] Package README, license, repository URL and dependency ranges are inspected. +- [ ] Package version is not `2.0.0`. + +## API Compatibility + +- [ ] Public API surface is reviewed for core, Dommel, DependencyInjection, Analyzers and Generators. +- [ ] Fork-owned API/binary baseline strategy is decided before stable. +- [ ] Any intentional incompatibility is documented in migration notes. +- [ ] No accidental public API additions are left unreviewed. + +## Documentation + +- [ ] README describes FluentMap as an advanced mapping layer for Dapper, not an ORM. +- [ ] README includes installation, quick start and supported modern APIs. +- [ ] README links to migration, compatibility, support and changelog documents. +- [ ] Documentation examples are validated against real APIs. +- [ ] PT-BR documentation remains present. + +## Migration + +- [ ] `MIGRATION.md` covers original FluentMap to current fork. +- [ ] Initialization and registration compatibility are explained. +- [ ] Conventions, nested objects, value objects, profiles, `Ignore()`, persistence semantics, isolated configuration and DI are covered. +- [ ] The guide avoids telling users to migrate compatible APIs unnecessarily. + +## CI + +- [ ] CI restore/build/test/pack workflow passes. +- [ ] Release workflow validates packages without publishing. +- [ ] Action pins are reviewed. +- [ ] SourceLink URL/checksum is validated after the commit is available remotely. +- [ ] Artifact retention and provenance behavior are confirmed in GitHub Actions. + +## Security + +- [ ] `dotnet list ./Dapper.FluentMap.sln package --vulnerable --include-transitive` reports no untriaged vulnerabilities. +- [ ] NuGet Audit behavior is verified in CI. +- [ ] No secrets are present in source or packages. +- [ ] Package signing decision is recorded. +- [ ] SBOM decision is recorded. + +## AOT / Trimming + +- [ ] Trimmed smoke for explicit registration passes or blockers are recorded. +- [ ] Trimmed smoke for generated registration passes or blockers are recorded. +- [ ] Trimmed smoke for DI registration passes or blockers are recorded. +- [ ] Known IL warnings are documented and not hidden. +- [ ] Native AOT is not claimed unless publish and execution are validated with the native toolchain. + +## Known Limitations + +- [ ] Global `FluentMapper`/Dapper/Dommel state is documented. +- [ ] Dommel runtime isolation limitation is documented. +- [ ] Provider certification limits are documented. +- [ ] QueryMultiple/streaming limits are documented. +- [ ] Write converter metadata-only behavior is documented. +- [ ] Generated materializer fallback behavior is documented. + +## Release Blockers + +- [ ] Version strategy is finalized for the fork line. +- [ ] API/binary baseline is established for stable promotion. +- [ ] SourceLink remote validation passes after push. +- [ ] Analyzer/generator release manifests are reviewed. +- [ ] SQL Server/PostgreSQL provider certification decision is made. +- [ ] SBOM and package signing are either implemented or explicitly deferred. +- [ ] NuGet publish remains disabled until trusted publishing/OIDC and approval gates are configured. diff --git a/.sdd/etapa-12/STATUS.md b/.sdd/etapa-12/STATUS.md index 6c9511f..713255e 100644 --- a/.sdd/etapa-12/STATUS.md +++ b/.sdd/etapa-12/STATUS.md @@ -69,6 +69,26 @@ publish NuGet ainda desabilitado. - Revisada decisao de strong naming: nao adicionar neste prompt. - Revisada package signing: nao assinar neste prompt. - Criado `06-ci-release-design.md`. +- Criado `07-release-candidate-checklist.md`. +- Reescrito `README.md` como porta de entrada bilingue mais objetiva, com + links para documentos dedicados. +- Criado `MIGRATION.md` para migracao do FluentMap historico para a linha atual + do fork. +- Criado `COMPATIBILITY.md` com matriz publica de .NET, Dapper, Dommel, + providers, AOT/trimming e limitacoes de estado global. +- Criado `SUPPORT.md` com politica simples de suporte, seguranca, previews e + reporte de issues. +- Criado `CHANGELOG.md` para a nova linha evolutiva do fork, sem reconstruir + artificialmente o historico antigo. +- Auditoria documental do Prompt 12.6: + - outdated: README ainda carregava texto de etapa anterior e parte dos + detalhes de release sem separar politica de compatibilidade/migracao; + - duplicated: README repetia secoes EN/PT longas e mantinha detalhes que + agora pertencem a documentos publicos dedicados; + - missing: nao havia `MIGRATION.md`, `COMPATIBILITY.md`, `SUPPORT.md`, + `CHANGELOG.md` nem checklist publico de RC; + - excessive: README estava grande demais para ser apenas porta de entrada de + adocao, especialmente com toda a explicacao avancada duplicada em EN/PT. - Adicionado `global.json` com SDK `10.0.302`. - Centralizado `VersionPrefix` dos pacotes em `FluentMapPackageVersionPrefix`. @@ -237,7 +257,7 @@ Resultados: ## Ultimo prompt executado -Ultimo prompt executado: 12.5 +Ultimo prompt executado: 12.6 ## Validacao do Prompt 12.3 @@ -374,3 +394,55 @@ Resultados: - Todos os `uses:` em workflows estao pinados por SHA completo. - Provenance nao foi executado localmente porque depende do ambiente GitHub Actions/OIDC. + +## Validacao do Prompt 12.6 + +Executada localmente em 2026-07-29: + +```bash +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-12.6-final +dotnet list ./Dapper.FluentMap.sln package --vulnerable --include-transitive +git diff --check +``` + +Resultados: + +- Smoke de compilacao dos exemplos documentais representativos: sucesso contra + APIs reais de core, DI, generator, profiles, converters, runtime isolado e + query helpers. O projeto temporario gerou avisos proprios de scratch e foi + removido; nenhum arquivo temporario entrou no git. +- Restore: sucesso. +- Build Release: sucesso, 0 warnings, 0 errors. +- Test solution: sucesso; 460 aprovados, 14 ignored/skipped, 0 falhas. +- Skips: 14 cenarios condicionais de provider compatibility para SQL Server e + PostgreSQL por ausencia das connection strings `DFM_SQLSERVER_CONNECTION_STRING` + e `DFM_POSTGRESQL_CONNECTION_STRING`. +- Pack final em `artifacts/packages-12.6-final`: sucesso; gerou 5 `.nupkg` e + 3 `.snupkg`. +- Conteudo dos pacotes inspecionado: + - runtime packages contem `README.md`, assembly e XML docs em + `lib/netstandard2.0`; + - analyzer/generator contem `README.md`, DLL e PDB em + `analyzers/dotnet/cs`. +- Vulnerability audit: nenhum pacote vulneravel encontrado nas fontes atuais. +- `git diff --check`: sem erros; apenas avisos esperados de normalizacao LF -> + CRLF no Windows para `README.md` e `.sdd/etapa-12/STATUS.md`. + +## Blockers restantes para 12.7 + +- Critical: estrategia final de versionamento do fork ainda precisa ser + confirmada antes de publicar, pois `2.0.0` ja existe para core/Dommel. +- Critical: baseline de API/binario do proprio fork ainda precisa ser + estabelecida antes de stable. +- High: SourceLink URL/checksum precisa ser validado em CI apos push. +- High: CI ainda nao certifica SQL Server/PostgreSQL com servicos reais nem + smokes trimming/AOT. +- High: interoperabilidade com Dapper TypeHandler depende de boundary interna + por reflection. +- Medium: manifests de release dos analyzers/generators precisam revisao antes + de stable. +- Medium: SBOM formal e package signing seguem decisao futura/adiada. +- Medium: package lock ou Central Package Management ainda nao foram decididos. diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..a681b07 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,22 @@ +# Changelog + +This project follows the spirit of [Keep a Changelog](https://keepachangelog.com/) and Semantic Versioning for the fork line. + +The historical archived package history is not reconstructed here. This changelog records the new maintained fork line. + +## [Unreleased] + +### Added + +- Public adoption documentation for README, migration, compatibility and support policy. +- Release candidate readiness checklist under `.sdd/etapa-12/`. + +### Changed + +- README is now a concise bilingual entrypoint and delegates detailed release/adoption policy to dedicated documents. + +## Fork Release Candidate Line + +The first fork release candidate is expected to use a prerelease version such as `3.0.0-rc.1`, unless API compatibility review proves a different versioning path is safer. + +Do not reuse `2.0.0` for the fork line because `Dapper.FluentMap` and `Dapper.FluentMap.Dommel` already have historical `2.0.0` packages. diff --git a/COMPATIBILITY.md b/COMPATIBILITY.md new file mode 100644 index 0000000..26bfbce --- /dev/null +++ b/COMPATIBILITY.md @@ -0,0 +1,123 @@ +# Compatibility + +This document describes what is currently validated by this repository. It avoids claims that are only supported by design intent. + +## Package Matrix + +| Package | TFM | Status | +| --- | --- | --- | +| `Dapper.FluentMap` | `netstandard2.0` | Core package. | +| `Dapper.FluentMap.Dommel` | `netstandard2.0` | Optional Dommel integration. | +| `Dapper.FluentMap.DependencyInjection` | `netstandard2.0` | Optional DI integration. | +| `Dapper.FluentMap.Analyzers` | `netstandard2.0` | Roslyn analyzer package. | +| `Dapper.FluentMap.Generators` | `netstandard2.0` | Roslyn source generator package. | + +Tests, provider compatibility tests, AOT smoke projects and benchmarks currently run on `net10.0`. That does not raise the minimum TFM for consumers. + +## Dapper + +Current package range: + +```text +Dapper [2.1.79,3.0.0) +``` + +Validated in the current matrix: + +| Dapper | Status | Notes | +| --- | --- | --- | +| `2.1.79` | Validated | Current minimum and matrix lane used by the repository. | + +Known risk: `Dapper.FluentMap` uses public Dapper APIs for type maps/readers, but TypeHandler interoperability depends on resolving `SqlMapper.TypeHandlerCache.Parse(object)` by reflection. This is covered by tests and remains the highest-risk Dapper compatibility boundary. + +## Dommel + +Current package range for `Dapper.FluentMap.Dommel`: + +```text +Dommel [3.5.3,4.0.0) +``` + +Validated in the current matrix: + +| Dommel | Dapper | Status | +| --- | --- | --- | +| `3.5.3` | `2.1.79` | Validated by the current Dommel integration tests. | + +Dommel integration is optional and process-wide. It uses global `DommelMapper` resolvers/builders and does not participate in isolated `FluentMapRuntime` configuration. + +## Providers + +Provider support is split into certification levels: + +| Provider | Status | Evidence | +| --- | --- | --- | +| SQLite (`Microsoft.Data.Sqlite`) | Validated | Automated provider compatibility tests cover basic reads, nested/value-object reads, `QueryMultipleMapped`, sync/async streaming and Dommel persistence. | +| Provider-independent ADO.NET readers | Validated for core behavior | Tests use `DataTableReader` and common ADO.NET contracts. | +| SQL Server (`Microsoft.Data.SqlClient`) | Not certified | Conditional harness exists via `DFM_SQLSERVER_CONNECTION_STRING`, but it is not executed in CI by default. | +| PostgreSQL (`Npgsql`) | Not certified | Conditional harness exists via `DFM_POSTGRESQL_CONNECTION_STRING`, but it is not executed in CI by default. | +| MySQL/MariaDB | Not validated | Dommel builder registration exists by design; no automated provider lane is present. | +| SQL Server CE | Legacy/upstream-limited | Dommel builder remains registered for compatibility; no modern validation lane is present. | + +Provider certification requires real integration tests against that provider and database. A Dommel SQL builder being registered is not the same as provider certification. + +## AOT And Trimming + +Current status: + +| Area | Status | +| --- | --- | +| Explicit map registration | Preferred for trimmed and Native AOT applications. | +| Generated registration | Preferred alternative to assembly scanning for maps in the current compilation. | +| Assembly scanning | Reflection-based and annotated as trimming-sensitive. | +| `QueryMapped*`, `ReadMapped*`, `QueryMultipleMapped`, streaming | Annotated with trimming/dynamic-code warnings because runtime fallback can occur. | +| Full Native AOT compatibility | Not claimed. | + +Trimmed smoke tests have passed for explicit, generated and DI scenarios with known warnings. Native AOT publish/run has not been validated locally because the environment lacked the native linker toolchain. + +## Global State Limitations + +The historical static bridge remains process-wide: + +- `FluentMapper.Initialize(...)` publishes global FluentMap state; +- normal `Dapper.Query()` uses Dapper's global `SqlMapper.SetTypeMap` per entity type; +- Dommel uses global `DommelMapper` resolvers/builders. + +Use `ImmutableFluentMapConfiguration` and `FluentMapRuntime` for isolated FluentMap-controlled materialization: + +```csharp +var runtime = new FluentMapConfigurationBuilder() + .AddMap() + .Build() + .CreateRuntime(); + +var customer = runtime.QueryMappedSingle(connection, sql); +``` + +That isolation applies to `QueryMapped*`, `ReadMapped*`, `QueryMultipleMapped`, streaming, profiles, converters, diagnostics and generated materializer lookup. It does not make normal Dapper queries or Dommel select a runtime per call. + +## API Compatibility + +The fork preserves the main historical source-compatible API surface where possible: + +- `FluentMapper.Initialize(...)`; +- `EntityMap`; +- `PropertyMap`; +- `Map(...).ToColumn(...)`; +- `Ignore()`; +- conventions; +- Dapper type map bridge; +- Dommel mapping types. + +The fork also adds public APIs for profiles, naming policies, generated materializers, persistence metadata, property converters, query helpers, immutable configuration, isolated runtime and DI. + +Stable release readiness still requires a formal fork-owned API/binary compatibility baseline after the first release candidate. + +## Unsupported Environments Or Claims + +- Dapper major versions outside `[2.1.79,3.0.0)` are not currently supported. +- Dommel major versions outside `[3.5.3,4.0.0)` are not currently supported. +- Full Native AOT support is not claimed. +- Provider behavior that has not been validated by real integration tests is not certified. +- Dommel configuration isolation per `FluentMapRuntime` is not supported. +- `QueryMultipleMappedAsync`, Dapper multi-mapping with `splitOn`, graph aggregation and CRUD generation are not implemented. diff --git a/MIGRATION.md b/MIGRATION.md new file mode 100644 index 0000000..b79ab7e --- /dev/null +++ b/MIGRATION.md @@ -0,0 +1,282 @@ +# Migration Guide + +This guide is for users moving from the historical archived `Dapper.FluentMap` line to the current fork. + +```text +Original FluentMap + ↓ +Current fork +``` + +Most existing root-level maps should not need source changes. The historical API remains supported while the fork adds opt-in capabilities for advanced materialization, generated registration, persistence metadata, property converters, profiles and isolated configuration. + +## What Stays Compatible + +The following patterns remain the compatibility path: + +```csharp +FluentMapper.Initialize(config => +{ + config.AddMap(); +}); +``` + +```csharp +public sealed class CustomerMap : EntityMap +{ + public CustomerMap() + { + Map(customer => customer.Id).ToColumn("customer_id"); + Map(customer => customer.Name).ToColumn("customer_name"); + Map(customer => customer.TransientValue).Ignore(); + } +} +``` + +Normal Dapper calls such as `connection.Query()` continue to use Dapper's global type map bridge for root-level mappings installed by `FluentMapper.Initialize(...)`. + +Do not migrate working historical maps just because newer APIs exist. Prefer the newer APIs when they solve a concrete problem. + +## Packages + +Install only the packages you use: + +| Package | When to install | +| --- | --- | +| `Dapper.FluentMap` | Core mapping and Dapper integration. | +| `Dapper.FluentMap.Dommel` | Dommel table/key/generated-column integration. | +| `Dapper.FluentMap.DependencyInjection` | DI registration of immutable configuration and runtime. | +| `Dapper.FluentMap.Analyzers` | Compile-time diagnostics for mapping mistakes. | +| `Dapper.FluentMap.Generators` | Generated registration and supported generated materializers. | + +## Initialize + +The historical static initialization remains supported: + +```csharp +FluentMapper.Initialize(config => +{ + config.AddMap(); + config.AddMap(); +}); + +FluentMapper.Validate(); +``` + +Use this when your process has one effective mapping configuration and you want normal `Dapper.Query()` calls to use FluentMap's global Dapper type map bridge. + +The current fork also publishes `FluentMapper.Configuration` and `FluentMapper.Runtime` after initialization. Existing code does not need to use those properties. + +## Registration + +Existing explicit registrations remain valid: + +```csharp +config.AddMap(); +config.AddMap(new CustomerMap()); +``` + +Assembly scanning also remains available: + +```csharp +config.AddMapsFromAssemblyContaining(); +``` + +For trimming and Native AOT deployments, prefer explicit registration or generated registration instead of assembly scanning. + +## Conventions + +Existing conventions remain supported: + +```csharp +config.AddConvention().ForEntity(); +``` + +The fork adds naming policies for common transformations: + +```csharp +config.UseNamingPolicy(NamingPolicy.SnakeCase, caseSensitive: false) + .ForEntity(); +``` + +Precedence remains explicit mapping first, then convention/naming policy, then Dapper default behavior. + +## Nested Objects + +Historical FluentMap mainly helps Dapper map root-level members. Nested object materialization in this fork is opt-in: + +```csharp +public sealed class CustomerMap : EntityMap +{ + public CustomerMap() + { + Map(customer => customer.Address.City).ToColumn("city"); + } +} + +var customer = connection.QueryMappedSingle( + "SELECT 'Sao Paulo' AS city;"); +``` + +Use `QueryMapped*`, `ReadMapped*`, `QueryMultipleMapped` or streaming helpers when FluentMap must materialize nested paths. Normal `Dapper.Query()` does not become a graph mapper. + +## Value Objects + +For a value object stored as a single database value, keep using Dapper `TypeHandler` when that representation is global for the type. + +For value objects stored through mapped components, use FluentMap-controlled materialization: + +```csharp +Map(customer => customer.Cpf.Number).ToColumn("cpf"); +``` + +The current materializer uses compatible public constructors. Factory methods are not used. + +## Profiles + +Profiles are new opt-in mappings for alternate SQL shapes: + +```csharp +public sealed class LegacyProfile : IMappingProfile +{ +} + +public sealed class LegacyCustomerMap : + EntityMap, + IProfileMap +{ + public LegacyCustomerMap() + { + Map(customer => customer.Name).ToColumn("legacy_name"); + } +} + +config.AddProfile(); + +var customer = connection.QueryMappedSingle(sql); +``` + +Profiles do not replace the default global Dapper type map. Select them per FluentMap-controlled query. + +## Ignore + +`Ignore()` keeps its historical meaning: the property is not mapped for FluentMap materialization and is excluded from generated persistence metadata. + +If historical Dommel code used `Ignore()` only to avoid writing a database-generated column while still reading it, migrate that mapping to persistence metadata: + +```csharp +Map(entity => entity.CreatedAt) + .ToColumn("created_at") + .DatabaseDefaultOnInsert(); + +Map(entity => entity.UpdatedAt) + .ToColumn("updated_at") + .ReadOnly(); + +Map(entity => entity.Total) + .ToColumn("total") + .Computed(); +``` + +Use `Ignore()` only for values that should not be materialized by FluentMap. + +## Persistence Semantics + +The core package stores persistence metadata. Dommel consumes this metadata for generated writes: + +| Mapping | Read | Insert | Update | +| --- | --- | --- | --- | +| default | yes | yes | yes | +| `Ignore()` | no | no | no | +| `ReadOnly()` | yes | no | no | +| `Computed()` | yes | no | no | +| `DatabaseDefaultOnInsert()` | yes | no | yes | +| `ExcludeFromInsert()` | yes | no | yes | +| `ExcludeFromUpdate()` | yes | yes | no | + +The core package still does not generate CRUD SQL. + +## Property Converters + +Property converters are new. They run only in FluentMap-controlled materialization: + +```csharp +Map(product => product.Status) + .ToColumn("status_code") + .ConvertFromDatabaseUsing(); +``` + +Normal `Dapper.Query()` does not execute property converters. Use Dapper `TypeHandler` for type-wide conversion. + +Write converter metadata exists, but Dapper/Dommel writes do not execute it yet. + +## Generated Registration + +Install `Dapper.FluentMap.Generators` and call: + +```csharp +config.AddGeneratedMappings(); +``` + +This can replace manual registration for eligible maps in the current compilation. It does not scan referenced assemblies and does not remove the need for runtime validation. + +Generated materializers are an optimization. Unsupported cases fall back to runtime materialization. + +## Configuration Isolation + +If your application needs multiple FluentMap configurations in the same process, use immutable configuration and runtime instances: + +```csharp +var runtime = new FluentMapConfigurationBuilder() + .AddMap() + .Build() + .CreateRuntime(); + +var customer = runtime.QueryMappedSingle(connection, sql); +``` + +This isolates FluentMap-controlled materialization. It does not isolate normal `Dapper.Query()` because Dapper type maps are global per entity type. + +## DI + +Install `Dapper.FluentMap.DependencyInjection` and register: + +```csharp +services.AddFluentMap(builder => +{ + builder.AddMap(); +}); +``` + +The DI package registers `ImmutableFluentMapConfiguration` and `FluentMapRuntime` as singletons. It does not register database connections, repositories, Dommel integration or global Dapper type maps. + +## Dommel + +Dommel remains optional and process-wide: + +```csharp +FluentMapper.Initialize(config => +{ + config.AddMap(); + config.ForDommel(); +}); +``` + +`DommelEntityMap`, `IsKey()`, `IsIdentity()` and `SetGeneratedOption(...)` remain the Dommel-specific mapping surface. Isolated FluentMap runtimes do not configure Dommel. + +## Breaking Or Risky Differences To Review + +- Dommel persistence metadata has new behavior for read-only, computed, insert-excluded and update-excluded properties. +- Some contradictory configurations that were previously accepted by accident now fail validation. +- `DommelPropertyMap.GeneratedOption` has changed from non-nullable to nullable in the fork line; treat binary compatibility with historical Dommel `2.0.0` as not guaranteed. +- Generated materialization and isolated runtime APIs are additive, but stable release still requires a fork-owned API baseline. + +## Recommended Migration Path + +1. Keep existing `EntityMap` maps and `FluentMapper.Initialize(...)`. +2. Run the full test suite of your application against the fork package. +3. Replace historical `Ignore()` write-workarounds with persistence metadata where needed. +4. Move nested/value-object reads to `QueryMapped*` only where required. +5. Add profiles only for alternate SQL shapes. +6. Add isolated runtime/DI only when you need multiple configurations or host integration. +7. Add analyzers and generators after the runtime behavior is already understood. diff --git a/README.md b/README.md index 3fdc508..780fe7c 100644 --- a/README.md +++ b/README.md @@ -2,46 +2,45 @@ [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. +FluentMap is an advanced mapping layer for Dapper. It lets you describe how .NET object properties map to database columns with fluent, strongly typed code, while keeping persistence attributes out of your POCOs. -> 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. +FluentMap is not an ORM. It does not track entities, build arbitrary SQL, manage connections, run migrations, provide LINQ, or replace Dapper. Use it when Dapper's default name-based mapping is not enough and the mapping rules should live outside the model. -## Why FluentMap? +This repository originated from the archived `Dapper.FluentMap` project and is being evolved in this fork. -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. +## Positioning -Use FluentMap to: +Use FluentMap for: -- 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. +- explicit property-to-column maps; +- conventions and naming policies; +- ignored properties; +- immutable constructor mapping; +- opt-in nested object and value object materialization; +- mapping profiles for alternate SQL shapes; +- generated map registration/materialization where supported; +- persistence metadata consumed by integrations such as Dommel; +- isolated configuration and dependency injection for FluentMap-controlled materialization. + +Do not use FluentMap as an ORM, CRUD framework, query builder, unit of work, or database abstraction. ## Installation -Install the package that matches the functionality you need: +Install the package that matches the feature set you need: | Package | Purpose | -|---|---| +| --- | --- | | `Dapper.FluentMap` | Core mapping API and Dapper integration. | -| `Dapper.FluentMap.DependencyInjection` | Optional Microsoft.Extensions.DependencyInjection 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: +| `Dapper.FluentMap.DependencyInjection` | Optional `Microsoft.Extensions.DependencyInjection` integration. | +| `Dapper.FluentMap.Analyzers` | Roslyn analyzers for statically provable mapping mistakes. | +| `Dapper.FluentMap.Generators` | Source generator for build-time map registration and generated materializers. | ```bash dotnet add package Dapper.FluentMap ``` -The core package targets `netstandard2.0` and depends on Dapper. +The public packages target `netstandard2.0`. See [COMPATIBILITY.md](COMPATIBILITY.md) before adopting a release candidate. ## Quick Start @@ -53,7 +52,6 @@ using Dapper.FluentMap.Mapping; public sealed class Customer { public int Id { get; set; } - public string Name { get; set; } } @@ -67,18 +65,18 @@ public sealed class CustomerMap : EntityMap FluentMapper.Initialize(config => { - config.AddMap(new CustomerMap()); + config.AddMap(); }); 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. +Call `FluentMapper.Initialize(...)` during application startup and treat the effective global configuration as read-only once queries begin. ## Mapping -Create a map by deriving from `EntityMap`: +Create maps by deriving from `EntityMap`: ```csharp public sealed class ProductMap : EntityMap @@ -87,432 +85,38 @@ public sealed class ProductMap : EntityMap { 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. - -Persistence metadata can describe write participation without changing read materialization: - -```csharp -Map(product => product.CreatedAt) - .ToColumn("created_at") - .DatabaseDefaultOnInsert(); - -Map(product => product.UpdatedAt) - .ToColumn("updated_at") - .ReadOnly(); - -Map(product => product.Total) - .Computed(); -``` - -### Ignore - -`Ignore()` keeps its historical meaning: the property does not participate in FluentMap materialization or generated persistence metadata. - -```csharp -Map(product => product.TransientValue) - .Ignore(); -``` - -Do not use `Ignore()` for database values that should still be selected. It is not the same as read-only persistence metadata. - -### Read-only - -Use `ReadOnly()` for database values that are selected but not written by generated persistence operations: - -```csharp -Map(product => product.UpdatedAt) - .ToColumn("updated_at") - .ReadOnly(); -``` - -```text -SELECT: participates -INSERT: excluded -UPDATE: excluded -``` - -### Database Defaults - -Use `DatabaseDefaultOnInsert()` when the database supplies the initial value if the column is omitted from `INSERT`, for example a `created_at DEFAULT ...` column: - -```csharp -Map(product => product.CreatedAt) - .ToColumn("created_at") - .DatabaseDefaultOnInsert(); -``` - -This excludes the property from generated `INSERT` metadata, keeps it readable, and keeps it updateable by default. Compose `.ExcludeFromUpdate()` when the value should remain database-controlled after insert. - -### Computed - -Use `Computed()` for values calculated by the database: - -```csharp -Map(product => product.Total) - .ToColumn("total") - .Computed(); -``` - -Computed properties participate in reads and are excluded from generated `INSERT` and `UPDATE` metadata. - -## Property Converters - -Property converters are configured on a specific mapping when a column value -needs member-specific conversion. They are useful when two properties of the -same CLR type need different database representations, or when a mapping -profile reads a legacy SQL shape differently from the default map. - -```csharp -public sealed class ProductMap : EntityMap -{ - public ProductMap() - { - Map(product => product.Status) - .ToColumn("status_code") - .ConvertFromDatabaseUsing(); - } -} - -public sealed class ProductStatusConverter : - IReadPropertyConverter -{ - public ProductStatus ConvertFromDatabase(string value) - { - return value == "A" ? ProductStatus.Active : ProductStatus.Inactive; - } -} -``` - -Converter instances may be reused by concurrent materialization operations. -Implementations should be stateless or otherwise thread-safe. - -### Read Conversion - -Read conversion runs only in FluentMap-controlled materialization: -`QueryMapped*`, `ReadMapped*`, `QueryMultipleMapped`, synchronous unbuffered -streaming and asynchronous unbuffered streaming. - -For those APIs the effective precedence is: - -```text -null/DBNull handling - -> property read converter - -> Dapper TypeHandler - -> FluentMap default conversion -``` - -`null` and `DBNull.Value` are not passed to read converters by default. -Nullable/reference targets receive `null`; non-nullable value types receive -`default(T)`. Normal Dapper queries such as `Query()` are unchanged and do -not execute FluentMap property converters. - -Generated materializers can emit read converter calls for converter types that -are statically supported, accessible and parameterless: - -```csharp -Map(product => product.Status) - .ToColumn("status_code") - .ConvertFromDatabaseUsing(); -``` - -Converters supplied by instance or delegate continue to use the runtime -materializer fallback. - -### Converter Metadata For Writes - -The core package can store write converter metadata: - -```csharp -Map(product => product.Status) - .ToColumn("status_code") - .ConvertToDatabaseUsing(); -``` - -This does not currently convert parameters for Dapper or Dommel operations. -`Insert`, `Update` and other Dommel writes keep using the original entity values -and Dapper/provider parameter handling. Write converter execution is deferred -until there is a supported parameter-value hook. - -### Profiles - -Converters configured in a profile map apply only when that profile is selected: - -```csharp -public sealed class LegacyProductMap : - EntityMap, - IProfileMap -{ - public LegacyProductMap() - { - Map(product => product.Status) - .ToColumn("legacy_status") - .ConvertFromDatabaseUsing(); + Map(product => product.TransientValue).Ignore(); } } - -var product = connection.QueryMappedSingle( - "SELECT '1' AS legacy_status;"); -``` - -Default-map converters do not automatically leak into profiles. Reuse must be -explicit, for example through `IncludeBase()` or by configuring the converter -again in the profile map. - -### Dapper TypeHandlers - -Use a Dapper `TypeHandler` when a type has one database representation across -the application. Use a FluentMap property converter when the conversion belongs -to one mapping, member path or profile. - -```text -TypeHandler -> behavior by type -Property Converter -> behavior by mapping/member/profile ``` -When both are present on a FluentMap-controlled read, the property read -converter wins for that mapped property. Without a property converter, -`QueryMapped*` uses the registered `TypeHandler` before FluentMap's -default conversion. Generated materializers do not call Dapper TypeHandlers in -this stage; scenarios that depend on TypeHandlers use the runtime fallback. +Explicit mappings take precedence over conventions. Unmapped root 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 - -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. - -### Static configuration - compatibility - -The historical static configuration remains supported: - -```csharp -FluentMapper.Initialize(config => -{ - config.AddMap(); -}); - -FluentMapper.Validate(); -``` - -This path publishes the default `ImmutableFluentMapConfiguration` and -`FluentMapRuntime` exposed by `FluentMapper.Configuration` and -`FluentMapper.Runtime`, and installs global Dapper type maps for default maps -and conventions. Use it when your process has one effective FluentMap -configuration and you want normal `connection.Query()` calls to use the -global Dapper type map bridge. - -### Isolated configuration - -You can build an immutable configuration snapshot without mutating the global -`FluentMapper` state: - -```csharp -using Dapper.FluentMap.Configuration; - -var configuration = new FluentMapConfigurationBuilder() - .AddMap() - .Configure(config => config.AddGeneratedMappings()) - .Build(); - -var runtime = configuration.CreateRuntime(); -``` - -`Build()` validates the same invariants used by `FluentMapper.Validate()` and -returns an `ImmutableFluentMapConfiguration` with read-only metadata for maps, -profiles, conventions, naming policies, persistence metadata, converters and -generated materializer registrations. The builder is sealed after `Build()`. -Create a `FluentMapRuntime` from the immutable configuration when multiple -configuration-specific `QueryMapped*` pipelines must coexist in the same -process. `FluentMapper.Initialize(...)` remains the global compatibility layer, -with `FluentMapper.Configuration` and `FluentMapper.Runtime` exposing the -currently published default configuration and runtime. - -### Multiple configurations - -Multiple configurations are supported for FluentMap-controlled materialization -when each operation uses the intended `FluentMapRuntime`: - -```csharp -var current = new FluentMapConfigurationBuilder() - .AddMap() - .Build() - .CreateRuntime(); - -var legacy = new FluentMapConfigurationBuilder() - .AddMap() - .Build() - .CreateRuntime(); - -var currentCustomer = current.QueryMappedSingle( - connection, - "SELECT 1 AS customer_id, 'Ada' AS customer_name;"); - -var legacyCustomer = legacy.QueryMappedSingle( - connection, - "SELECT 2 AS customer_id, 'Grace' AS legacy_name;"); -``` - -This isolation applies to `QueryMapped*`, `ReadMapped*`, -`QueryMultipleMapped`, profiles, converters, generated materializers and -runtime diagnostics. It does not make normal `Dapper.Query()` or Dommel -select a FluentMap runtime per call. - -### Test isolation - -Tests can create a local builder and runtime instead of resetting global -FluentMap state: - -```csharp -var runtime = new FluentMapConfigurationBuilder() - .AddMap() - .Build() - .CreateRuntime(); - -var customer = runtime.QueryMappedSingle( - connection, - "SELECT 42 AS customer_id, 'Grace' AS Name;"); -``` - -This lets tests use different mappings for the same entity type in the same -process. Tests that exercise `FluentMapper.Initialize(...)`, direct -`FluentMapper.EntityMaps` mutation, normal Dapper type maps or Dommel still -touch process-wide state and should remain isolated accordingly. - -## Dependency Injection - -Install `Dapper.FluentMap.DependencyInjection` when using ASP.NET Core, Worker -Services or the generic host: - -```bash -dotnet add package Dapper.FluentMap.DependencyInjection -``` - -Register FluentMap during service composition: - -```csharp -using Microsoft.Extensions.DependencyInjection; - -services.AddFluentMap(builder => -{ - builder.AddMap(); - builder.Configure(config => config.AddGeneratedMappings()); -}); -``` - -`AddFluentMap(...)` builds and validates the immutable configuration -immediately, then registers both `ImmutableFluentMapConfiguration` and -`FluentMapRuntime` as singletons. Use the resolved runtime with the -configuration-aware query APIs: - -```csharp -public sealed class CustomerReader -{ - private readonly FluentMapRuntime _runtime; - - public CustomerReader(FluentMapRuntime runtime) - { - _runtime = runtime; - } - - public Customer Read(IDbConnection connection) - { - return _runtime.QueryMappedSingle( - connection, - "SELECT 7 AS customer_id, 'Ada' AS name;"); - } -} -``` - -The DI package does not register database connections, repositories, Dommel -bridges or global Dapper type maps. Use explicit or generated registration for -trimmed and Native AOT applications; assembly scanning remains available but is -not the recommended DI path for those deployments. - -## Conventions and Naming Policies - -Conventions let you map repeated column patterns: +Conventions and naming policies cover repeated patterns: ```csharp using Dapper.FluentMap.Conventions; +using Dapper.FluentMap.Naming; public sealed class PrefixConvention : Convention { public PrefixConvention() { - Properties() - .Configure(property => property.HasPrefix("col")); + Properties().Configure(property => property.HasPrefix("col")); } } FluentMapper.Initialize(config => { - config.AddConvention() - .ForEntity(); -}); -``` - -Naming policies cover common name transformations: - -```csharp -using Dapper.FluentMap.Naming; - -FluentMapper.Initialize(config => -{ + config.AddConvention().ForEntity(); config.UseNamingPolicy(NamingPolicy.SnakeCase, caseSensitive: false) - .ForEntity(); + .ForEntity(); }); ``` -Available policies include `Identity`, `SnakeCase`, `Prefix(...)`, `Suffix(...)`, `Custom(...)` and composition with `Then(...)`, `WithPrefix(...)` and `WithSuffix(...)`. +Available naming policies include `Identity`, `SnakeCase`, `Prefix(...)`, `Suffix(...)`, `Custom(...)`, `Then(...)`, `WithPrefix(...)` and `WithSuffix(...)`. -## Immutable Types and Constructor Mapping +## Immutable Types FluentMap participates in Dapper constructor mapping for root-level explicit mappings: @@ -526,7 +130,6 @@ public sealed class Customer } public int Id { get; } - public string FullName { get; } } @@ -540,11 +143,11 @@ public sealed class CustomerMap : EntityMap } ``` -When you need FluentMap to build nested immutable objects or value objects, use `QueryMapped*`. +Use `QueryMapped*` when FluentMap must construct nested immutable objects or value objects. -## Nested Object Mapping +## Nested Objects -Nested member paths can be configured with the same `Map(...)` API: +Nested member paths use the same `Map(...)` API: ```csharp public sealed class CustomerMap : EntityMap @@ -555,26 +158,22 @@ public sealed class CustomerMap : EntityMap 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 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`. +Nested object materialization is opt-in through `QueryMapped*`, `ReadMapped*`, `QueryMultipleMapped` and streaming helpers. Normal `Dapper.Query()` remains root-level Dapper materialization. ## Value Objects -For scalar value objects mapped as a whole property, prefer a Dapper `TypeHandler`: +For scalar value objects mapped as one database value, prefer a Dapper `TypeHandler`: ```csharp Map(customer => customer.Cpf).ToColumn("cpf"); ``` -For value objects mapped through their components, `QueryMapped*` can construct them through matching public constructors: +For value objects mapped through components, FluentMap-controlled materialization can call matching public constructors: ```csharp public sealed class CustomerMap : EntityMap @@ -590,9 +189,9 @@ var customer = connection.QueryMappedSingle( "SELECT 1 AS customer_id, '12345678909' AS cpf;"); ``` -Factory methods are not used by the current runtime materializer. +Factory methods are not used by the current materializer. -## Mapping Profiles +## Profiles Profiles are opt-in mappings for the same entity under different SQL shapes: @@ -624,364 +223,32 @@ var legacy = connection.QueryMappedSingle( "SELECT 7 AS id, 'Legacy Ltd.' AS legal_name;"); ``` -Profiles are selected per `QueryMapped()` operation. They do not replace the global Dapper type map for the entity. - -Profiles can also be selected per result set when using mapped multiple results: +Profiles are selected per FluentMap-controlled query. They do not replace the global Dapper type map for the entity. -```csharp -using var multi = connection.QueryMultipleMapped(sql); +## Generated Materialization -var currentCustomers = multi.ReadMapped(); -var legacyCustomers = multi.ReadMapped(); -``` - -Use `ReadMappedSingle()` or `ReadMappedSingle()` when the current result set must contain exactly one row. - -## Diagnostics - -Use runtime validation to fail fast after configuration: - -```csharp -FluentMapper.Validate(); -``` - -Use `Explain()` or `Explain()` to inspect the effective mapping: - -```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()`: - -```csharp -FluentMapper.Initialize(config => -{ - config.AddGeneratedMappings(); -}); -``` - -Generated registration calls the existing `AddMap()` / `AddProfile()` paths. For explicit maps with literal columns and supported deterministic construction, it also registers generated row materializers for the matching ordered column shape, including flat properties, nested object paths, constructor-built Value Objects and statically supported property read converters. Unsupported maps and unexpected shapes continue to use the runtime fallback. It does not scan referenced assemblies, execute map constructors during generation or replace `FluentMapper.Validate()`. - -The core runtime also exposes low-level generated materializer registration contracts for generator-emitted code. These contracts are additive infrastructure; current consumers do not need to register materializers manually, and missing generated materializers continue to use the existing runtime fallback. - -## 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. `QueryMapped*` keeps its trimming and dynamic-code annotations even when a generated materializer is available, because unsupported shapes can still fall back to the runtime materializer. - -## 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); -connection.QueryMappedUnbuffered(sql); -connection.QueryMappedUnbufferedAsync(sql, cancellationToken); - -using var multi = connection.QueryMultipleMapped(sql); -var customers = multi.ReadMapped(); -var orders = multi.ReadMapped(); -``` - -`QueryMapped*` and `ReadMapped*` return buffered results and are the paths that support nested object materialization, constructor-built value objects and profile-specific mapping. When a generated materializer is registered for the entity, profile and ordered column shape, these APIs use it; otherwise they use the runtime materializer fallback. - -For independent configurations in the same process, create a runtime from an -immutable configuration and use its query entry points: - -```csharp -var configuration = new FluentMapConfigurationBuilder() - .AddMap() - .Build(); - -var runtime = configuration.CreateRuntime(); -var customers = runtime.QueryMapped( - connection, - "SELECT 7 AS customer_id, 'Ada' AS Name;"); -``` - -The runtime owns configuration-scoped caches for mapping lookup, generated -materializer lookup and runtime materialization plans. It is safe to share across -concurrent queries when the configuration is immutable. - -Use `QueryMultipleMapped(...)` when one command returns multiple result sets that all need FluentMap-controlled materialization: - -```csharp -var sql = @" - SELECT 1 AS customer_id, 'Ada' AS customer_name; - SELECT 10 AS order_id, 42.50 AS total;"; - -using var multi = connection.QueryMultipleMapped(sql); - -var customers = multi.ReadMapped().ToList(); -var orders = multi.ReadMapped().ToList(); -``` - -Result sets are consumed sequentially. `ReadMapped()` and `ReadMapped()` buffer the current result set, advance to the next one and keep the underlying reader open until all result sets are consumed or the `MappedGridReader` is disposed. - -Profiles can be selected per result set: - -```csharp -using var multi = connection.QueryMultipleMapped(sql); - -var currentCustomers = multi.ReadMapped(); -var legacyCustomers = multi.ReadMapped(); -``` - -Use `QueryMappedUnbuffered()` or `QueryMappedUnbuffered()` when you need to process a large result set incrementally: - -```csharp -foreach (var customer in connection.QueryMappedUnbuffered(sql)) -{ - Process(customer); -} -``` - -Unbuffered queries are lazy: the command is executed when enumeration starts, not when the method is called. The underlying reader stays open until enumeration finishes or the enumerator is disposed. If FluentMap opens a closed connection for the enumeration, disposing the reader closes it again; if the connection was already open, it remains open and must stay usable for the whole enumeration. Dispose the enumerator, for example by using `foreach`, when stopping early. - -Use `QueryMappedUnbufferedAsync()` or `QueryMappedUnbufferedAsync()` on `DbConnection` when the provider supports asynchronous readers: - -```csharp -using var cancellation = new CancellationTokenSource(); - -await foreach (var customer in connection.QueryMappedUnbufferedAsync( - sql, - cancellation.Token)) -{ - await ProcessAsync(customer, cancellation.Token); -} -``` - -Async unbuffered queries are also lazy and incremental. FluentMap awaits command execution and `DbDataReader.ReadAsync(...)`, propagates cancellation to supported async operations, and disposes the reader when enumeration completes, stops early, is canceled or throws. Row materialization remains synchronous after the row has been read; generated materializers and runtime fallback use the same dispatch as buffered and synchronous unbuffered queries. - -`QueryMultipleMapped` is about multiple result sets, not Dapper multi-mapping with `splitOn`. FluentMap does not perform graph aggregation, identity maps or automatic join grouping; write the SQL shape you need and choose the mapped helper only when FluentMap should materialize each row. - -## 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() - { - ToTable("products"); - Map(product => product.Id).ToColumn("product_id").IsKey().IsIdentity(); - } -} -``` - -Enable Dommel integration during FluentMap configuration: - -```csharp -FluentMapper.Initialize(config => -{ - config.AddMap(new ProductMap()); - config.ForDommel(); -}); -``` - -Dommel honors FluentMap persistence metadata for generated `INSERT` and `UPDATE` -commands. `ReadOnly()` and `Computed()` are selected but not written, -`DatabaseDefaultOnInsert()` and `ExcludeFromInsert()` are omitted from `INSERT` -while remaining updateable, and `ExcludeFromUpdate()` remains insertable but is -not written by `UPDATE`. These behaviors are metadata in the core package; Dommel -is the package that turns them into generated SQL behavior. - -Key metadata is Dommel-specific: - -```csharp -Map(product => product.Id) - .ToColumn("product_id") - .IsKey() - .IsIdentity(); - -Map(product => product.Code) - .ToColumn("product_code") - .IsKey() - .SetGeneratedOption(DatabaseGeneratedOption.None); -``` - -`IsKey()` identifies the row. `IsIdentity()` marks a database-generated identity -key, excluded from `INSERT` and from `UPDATE SET`. A non-identity key is assigned -by the application, participates in `INSERT`, and is used by Dommel in the -`UPDATE WHERE` clause rather than in `UPDATE SET`. - -### Compatibility Notes - -Historical FluentMap code sometimes used `Ignore()` to keep a property out of -Dommel `INSERT` or `UPDATE`. Keep `Ignore()` only for values that should not be -materialized. For database-generated values that must still be read, use the -persistence behavior that matches the intent: `ReadOnly()`, `Computed()`, -`DatabaseDefaultOnInsert()`, `ExcludeFromInsert()` or `ExcludeFromUpdate()`. - -## Current Limitations - -- `FluentMapper.Initialize(...)`, `Dapper.Query()` and Dommel still use process-wide compatibility bridges. Use `ImmutableFluentMapConfiguration` + `FluentMapRuntime` with `QueryMapped*`/`ReadMapped*` when multiple configurations must coexist in the same process. -- Multiple configurations are isolated only for FluentMap-controlled materialization. Normal `Dapper.Query()` uses the global `SqlMapper.SetTypeMap` registered for the entity type. -- Dommel integration uses global `DommelMapper` resolvers/builders and reads the legacy process-wide FluentMap collections; isolated core runtimes do not configure Dommel. -- Assembly scanning depends on reflection discovery and is not the recommended path for trimmed or Native AOT applications. -- `QueryMapped*` may use generated materializers for supported flat, nested and Value Object shapes, but it can still fall back to runtime metadata and dynamic code; it is not yet a guaranteed Native AOT-safe materialization path. -- Property converters are not a general object mapper, serializer, SQL hook or replacement for Dapper `TypeHandler`. -- Write converters are metadata-only in the current Dommel integration and are not executed by `Insert` or `Update`. -- Converter type overloads require a public parameterless constructor; instance and delegate overloads are the preferred runtime configuration forms when a converter needs explicit construction. -- Generated read conversion supports statically visible converter types; converter instances, delegates, inaccessible converter types and unsupported fluent patterns use runtime fallback. -- Mapping profiles are selected through `QueryMapped()` and `ReadMapped()` APIs. -- `QueryMapped*` and `ReadMapped*` are buffered. Use `QueryMappedUnbuffered*` for explicit synchronous or asynchronous unbuffered streaming. -- `QueryMultipleMapped` consumes result sets sequentially and does not support concurrent reads from the same `MappedGridReader`. -- Streaming keeps the underlying reader open. Do not use the same connection concurrently while a reader is active unless the provider explicitly supports that usage. -- Multiple result sets are not Dapper multi-mapping by `splitOn`; FluentMap does not perform graph aggregation or automatic join grouping. -- 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 -``` - -## 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.DependencyInjection` | Integração opcional com Microsoft.Extensions.DependencyInjection. | -| `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: +Install `Dapper.FluentMap.Generators` when you want generated registration for maps in the current compilation: ```bash -dotnet add package Dapper.FluentMap +dotnet add package Dapper.FluentMap.Generators ``` -O pacote principal tem target `netstandard2.0` e depende do Dapper. - -## Início Rápido +Then call the generated extension: ```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()); + config.AddGeneratedMappings(); }); - -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`: +The generator emits `AddMap()` and `AddProfile()` calls for eligible maps. For supported explicit mappings it can also register generated row materializers for the ordered column shape, including flat properties, nested paths, constructor-built value objects and statically supported read converters. -```csharp -public sealed class ProductMap : EntityMap -{ - public ProductMap() - { - Map(product => product.Id).ToColumn("product_id"); - Map(product => product.Name).ToColumn("product_name", caseSensitive: false); - Map(product => product.LastModified).Ignore(); - } -} -``` +Generated materialization is an optimization. Unsupported maps, dynamic shapes, shape mismatches, instance/delegate converters and some advanced patterns use the runtime fallback. -Mapeamentos explícitos têm precedência sobre convenções. Membros não mapeados usam o comportamento normal do Dapper. +## Persistence Semantics -Metadata de persistência pode descrever participação em escrita sem alterar a materialização de leitura: +Persistence metadata describes write participation without changing read materialization: ```csharp Map(product => product.CreatedAt) @@ -993,416 +260,316 @@ Map(product => product.UpdatedAt) .ReadOnly(); Map(product => product.Total) + .ToColumn("total") .Computed(); ``` -### Ignore - -`Ignore()` mantém seu significado histórico: a propriedade não participa da materialização do FluentMap nem da metadata de persistência gerada. - -```csharp -Map(product => product.TransientValue) - .Ignore(); -``` +`Ignore()` keeps its historical meaning: the property is not materialized by FluentMap and is not part of generated persistence metadata. For database values that should still be selected but not written, use `ReadOnly()`, `Computed()`, `DatabaseDefaultOnInsert()`, `ExcludeFromInsert()` or `ExcludeFromUpdate()`. -Não use `Ignore()` para valores do banco que ainda devem ser selecionados. Ele não é o mesmo que metadata de persistência read-only. +The core package stores metadata. Dommel is the current package that consumes it for generated `INSERT` and `UPDATE` behavior. -### Read-only +## QueryMultiple / Streaming -Use `ReadOnly()` para valores do banco que são selecionados, mas não escritos por operações de persistência geradas: +Use FluentMap query helpers when materialization must honor nested mappings, value objects, profiles, converters or generated materializers: ```csharp -Map(product => product.UpdatedAt) - .ToColumn("updated_at") - .ReadOnly(); -``` - -```text -SELECT: participa -INSERT: excluido -UPDATE: excluido +var customers = connection.QueryMapped(sql); +var customer = connection.QueryMappedSingle(sql); +var legacy = connection.QueryMappedSingle(legacySql); ``` -### Defaults de Banco - -Use `DatabaseDefaultOnInsert()` quando o banco fornece o valor inicial se a coluna for omitida do `INSERT`, por exemplo uma coluna `created_at DEFAULT ...`: +For multiple result sets: ```csharp -Map(product => product.CreatedAt) - .ToColumn("created_at") - .DatabaseDefaultOnInsert(); -``` - -Isso exclui a propriedade da metadata de `INSERT` gerado, mantém a leitura e preserva `UPDATE` por default. Componha `.ExcludeFromUpdate()` quando o valor também deve permanecer controlado pelo banco depois do insert. - -### Computed - -Use `Computed()` para valores calculados pelo banco: +using var multi = connection.QueryMultipleMapped(sql); -```csharp -Map(product => product.Total) - .ToColumn("total") - .Computed(); +var customers = multi.ReadMapped(); +var orders = multi.ReadMapped(); ``` -Propriedades computed participam de leituras e são excluídas da metadata de `INSERT` e `UPDATE` gerados. - -## Conversores de Propriedade +`ReadMapped*` consumes result sets sequentially and buffers the current result set. -Conversores de propriedade sao configurados em um mapping especifico quando um -valor de coluna precisa de conversao local ao membro. Eles sao uteis quando duas -propriedades do mesmo tipo CLR precisam de representacoes de banco diferentes, -ou quando um mapping profile le um shape SQL legado de forma diferente do map -default. +For incremental processing: ```csharp -public sealed class ProductMap : EntityMap -{ - public ProductMap() - { - Map(product => product.Status) - .ToColumn("status_code") - .ConvertFromDatabaseUsing(); - } -} - -public sealed class ProductStatusConverter : - IReadPropertyConverter +foreach (var customer in connection.QueryMappedUnbuffered(sql)) { - public ProductStatus ConvertFromDatabase(string value) - { - return value == "A" ? ProductStatus.Active : ProductStatus.Inactive; - } + Process(customer); } ``` -Instancias de converter podem ser reutilizadas por materializacoes concorrentes. -Implementacoes devem ser stateless ou thread-safe. - -### Conversao de Leitura - -Conversao de leitura executa somente na materializacao controlada pelo -FluentMap: `QueryMapped*`, `ReadMapped*`, `QueryMultipleMapped`, streaming -unbuffered sincrono e streaming unbuffered assincrono. - -Para essas APIs, a precedencia efetiva e: - -```text -tratamento de null/DBNull - -> property read converter - -> Dapper TypeHandler - -> conversao padrao do FluentMap -``` - -`null` e `DBNull.Value` nao sao enviados aos read converters por default. -Targets nullable/reference recebem `null`; value types nao nullable recebem -`default(T)`. Consultas Dapper normais, como `Query()`, nao mudam e nao -executam converters de propriedade do FluentMap. - -Materializadores gerados podem emitir chamadas de read converter para converter -types suportados estaticamente, acessiveis e parameterless: +Async streaming is available on `DbConnection`: ```csharp -Map(product => product.Status) - .ToColumn("status_code") - .ConvertFromDatabaseUsing(); -``` - -Converters fornecidos por instancia ou delegate continuam usando fallback do -materializador de runtime. - -### Metadata de Conversao para Escrita - -O pacote core consegue armazenar metadata de write converter: - -```csharp -Map(product => product.Status) - .ToColumn("status_code") - .ConvertToDatabaseUsing(); +await foreach (var customer in connection.QueryMappedUnbufferedAsync( + sql, + cancellationToken)) +{ + await ProcessAsync(customer, cancellationToken); +} ``` -Isso ainda nao converte parametros em operacoes Dapper ou Dommel. `Insert`, -`Update` e outras escritas Dommel continuam usando os valores originais da -entidade e a parametrizacao do Dapper/provider. A execucao de write converters -fica adiada ate existir um hook suportado para valores de parametros. +Streaming keeps the underlying reader open until enumeration completes or the enumerator is disposed. -### Profiles +## Property Converters -Converters configurados em um profile map valem somente quando aquele profile e -selecionado: +Property converters are configured per mapped property and run only during FluentMap-controlled materialization: ```csharp -public sealed class LegacyProductMap : - EntityMap, - IProfileMap +public sealed class ProductMap : EntityMap { - public LegacyProductMap() + public ProductMap() { Map(product => product.Status) - .ToColumn("legacy_status") - .ConvertFromDatabaseUsing(); + .ToColumn("status_code") + .ConvertFromDatabaseUsing(); } } -var product = connection.QueryMappedSingle( - "SELECT '1' AS legacy_status;"); +public sealed class ProductStatusConverter : + IReadPropertyConverter +{ + public ProductStatus ConvertFromDatabase(string value) + { + return value == "A" ? ProductStatus.Active : ProductStatus.Inactive; + } +} ``` -Converters do map default nao vazam automaticamente para profiles. Reuso deve -ser explicito, por exemplo com `IncludeBase()` ou configurando o converter -novamente no profile map. - -### Dapper TypeHandlers - -Use um `TypeHandler` do Dapper quando um tipo tem uma representacao de banco -unica na aplicacao. Use um property converter do FluentMap quando a conversao -pertence a um mapping, member path ou profile especifico. +Read conversion precedence in FluentMap-controlled materialization is: ```text -TypeHandler -> comportamento por tipo -Property Converter -> comportamento por mapping/member/profile +null/DBNull handling + -> property read converter + -> Dapper TypeHandler + -> FluentMap default conversion ``` -Quando ambos existem em uma leitura controlada pelo FluentMap, o property read -converter tem precedencia naquela propriedade mapeada. Sem property converter, -`QueryMapped*` usa o `TypeHandler` registrado antes da conversao -padrao do FluentMap. Materializadores gerados nao chamam TypeHandlers do Dapper -nesta etapa; cenarios que dependem de TypeHandlers usam fallback runtime. +Write converter metadata can be configured, but it is not currently executed by Dapper or Dommel writes. + +## Isolated Configuration / DI -Mapeamentos explícitos herdados podem ser incluídos quando a entidade derivada deve reutilizar um map da entidade base: +The historical static API remains supported: ```csharp -public sealed class PreferredCustomerMap : EntityMap +FluentMapper.Initialize(config => { - public PreferredCustomerMap() - { - IncludeBase(); - Map(customer => customer.Tier).ToColumn("tier"); - } -} + config.AddMap(); +}); ``` -Registre o map da base antes do map derivado. +For multiple FluentMap-controlled configurations in the same process, build immutable configurations and use their runtimes: -## Configuração +```csharp +using Dapper.FluentMap.Configuration; -Registre maps explicitamente: +var runtime = new FluentMapConfigurationBuilder() + .AddMap() + .Build() + .CreateRuntime(); -```csharp -FluentMapper.Initialize(config => -{ - config.AddMap(); - config.AddMap(); -}); +var customer = runtime.QueryMappedSingle( + connection, + "SELECT 7 AS customer_id, 'Ada' AS Name;"); ``` -Assembly scanning está disponível para cenários normais de runtime: +Install `Dapper.FluentMap.DependencyInjection` for DI registration: ```csharp -FluentMapper.Initialize(config => +using Microsoft.Extensions.DependencyInjection; + +services.AddFluentMap(builder => { - config.AddMapsFromAssemblyContaining(); - config.AddMapsFromAssembly(typeof(CustomerMap).Assembly, "App.Domain.Maps"); + builder.AddMap(); + builder.Configure(config => config.AddGeneratedMappings()); }); ``` -Use registro explícito em aplicações com trimming ou Native AOT. +The DI package registers `ImmutableFluentMapConfiguration` and `FluentMapRuntime` as singletons. It does not register database connections, repositories, Dommel bridges or global Dapper type maps. -Você pode validar a configuração atual depois do registro: +## AOT / Trimming -```csharp -FluentMapper.Initialize(config => config.AddMap()); -FluentMapper.Validate(); -``` +FluentMap has partial trimming/AOT readiness, not full Native AOT compatibility: -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. +| Area | Status | +| --- | --- | +| Explicit registration with `AddMap()` | Preferred for trimming and Native AOT scenarios. | +| Generated registration with `AddGeneratedMappings()` | Preferred alternative to assembly scanning for maps in the current compilation. | +| Assembly scanning | Reflection-based and annotated as trimming-sensitive. | +| `QueryMapped*`, `ReadMapped*`, `QueryMultipleMapped`, streaming | Annotated as trimming/dynamic-code sensitive because runtime fallback can occur. | -### Configuração Estática - Compatibilidade +Do not treat the package as fully Native AOT safe unless your application validates the exact query path and deployment mode. -A configuração estática histórica continua suportada: +## Compatibility -```csharp -FluentMapper.Initialize(config => -{ - config.AddMap(); -}); +Current compatibility documentation lives in [COMPATIBILITY.md](COMPATIBILITY.md). -FluentMapper.Validate(); -``` +Short version: -Esse caminho publica o `ImmutableFluentMapConfiguration` e o -`FluentMapRuntime` default expostos por `FluentMapper.Configuration` e -`FluentMapper.Runtime`, e instala type maps globais do Dapper para maps default -e conventions. Use esse caminho quando o processo possui uma única configuração -FluentMap efetiva e você quer que chamadas normais a `connection.Query()` -usem a bridge global de type map do Dapper. +- public packages target `netstandard2.0`; +- tests currently run on `net10.0`; +- Dapper range is `[2.1.79,3.0.0)`, with `2.1.79` validated in the current matrix; +- Dommel range is `[3.5.3,4.0.0)` for the optional Dommel package; +- SQLite is validated by automated provider tests; +- SQL Server and PostgreSQL have conditional harnesses but are not certified in CI yet; +- MySQL/MariaDB is not validated; +- SQL Server CE remains legacy/upstream-limited. -### Configuração Isolada +For users moving from the historical FluentMap package, see [MIGRATION.md](MIGRATION.md). -Tambem e possivel construir um snapshot imutavel sem alterar o estado global do -`FluentMapper`: +## Current Limitations -```csharp -using Dapper.FluentMap.Configuration; +- `FluentMapper.Initialize(...)`, normal `Dapper.Query()` and Dommel integrations use process-wide global state. +- Isolated runtimes apply to FluentMap-controlled materialization, not to normal Dapper queries or Dommel. +- Dommel uses global `DommelMapper` resolvers/builders. +- `QueryMultipleMapped` is sequential and buffered per result set; there is no `QueryMultipleMappedAsync`. +- `QueryMultipleMapped` is not Dapper multi-mapping with `splitOn`. +- FluentMap does not aggregate joined rows into graphs or maintain identity maps. +- Write converters are metadata-only in the current Dapper/Dommel write path. +- Generated materializers cover a supported subset and can fall back to runtime materialization. +- Assembly scanning and runtime fallback are trimming/AOT-sensitive. +- Value object construction uses compatible public constructors, not factory methods. -var configuration = new FluentMapConfigurationBuilder() - .AddMap() - .Configure(config => config.AddGeneratedMappings()) - .Build(); +## More Documentation -var runtime = configuration.CreateRuntime(); -``` +- [MIGRATION.md](MIGRATION.md) +- [COMPATIBILITY.md](COMPATIBILITY.md) +- [SUPPORT.md](SUPPORT.md) +- [CHANGELOG.md](CHANGELOG.md) + +## Contributing -`Build()` valida os mesmos invariants usados por `FluentMapper.Validate()` e -retorna um `ImmutableFluentMapConfiguration` com metadata read-only para maps, -profiles, conventions, naming policies, persistence metadata, converters e -generated materializer registrations. O builder fica selado depois de `Build()`. -Crie um `FluentMapRuntime` a partir da configuracao imutavel quando multiplos -pipelines `QueryMapped*` especificos por configuracao precisarem coexistir no -mesmo processo. `FluentMapper.Initialize(...)` continua sendo a camada global de -compatibilidade, com `FluentMapper.Configuration` e `FluentMapper.Runtime` -expondo a configuracao e o runtime default publicados. +Keep changes small, compatible with the public API and covered by focused tests. Typical local validation: -### Múltiplas Configurações +```bash +dotnet restore ./Dapper.FluentMap.sln +dotnet build ./Dapper.FluentMap.sln --configuration Release --no-restore +dotnet test ./Dapper.FluentMap.sln --configuration Release --no-build +``` -Múltiplas configurações são suportadas para materialização controlada pelo -FluentMap quando cada operação usa o `FluentMapRuntime` correto: +## License -```csharp -var current = new FluentMapConfigurationBuilder() - .AddMap() - .Build() - .CreateRuntime(); +FluentMap is licensed under the [MIT License](LICENSE). -var legacy = new FluentMapConfigurationBuilder() - .AddMap() - .Build() - .CreateRuntime(); +# Português (Brasil) -var currentCustomer = current.QueryMappedSingle( - connection, - "SELECT 1 AS customer_id, 'Ada' AS customer_name;"); +FluentMap é uma camada avançada de mapeamento para Dapper. Ela permite descrever, com uma API fluente e fortemente tipada, como propriedades .NET se conectam a colunas de banco de dados, mantendo atributos de persistência fora dos POCOs. -var legacyCustomer = legacy.QueryMappedSingle( - connection, - "SELECT 2 AS customer_id, 'Grace' AS legacy_name;"); -``` +FluentMap não é um ORM. Ele não faz tracking de entidades, não gera SQL arbitrário, não gerencia conexões, não executa migrations, não oferece LINQ e não substitui o Dapper. -Esse isolamento vale para `QueryMapped*`, `ReadMapped*`, -`QueryMultipleMapped`, profiles, converters, materializadores gerados e -diagnósticos do runtime. Ele não faz `Dapper.Query()` normal nem Dommel -selecionarem um runtime FluentMap por chamada. +Este repositório nasceu do projeto arquivado `Dapper.FluentMap` e está sendo evoluído neste fork. -### Isolamento de Testes +## Posicionamento -Testes podem criar um builder e runtime locais em vez de resetar o estado -global do FluentMap: +Use FluentMap para: -```csharp -var runtime = new FluentMapConfigurationBuilder() - .AddMap() - .Build() - .CreateRuntime(); +- mappings explícitos entre propriedades e colunas; +- convenções e políticas de nomenclatura; +- propriedades ignoradas; +- constructor mapping para tipos imutáveis; +- materialização opt-in de objetos aninhados e value objects; +- profiles para formatos SQL alternativos; +- registro e materialização gerados quando suportados; +- metadata de persistência consumida por integrações como Dommel; +- configuração isolada e DI para materialização controlada pelo FluentMap. -var customer = runtime.QueryMappedSingle( - connection, - "SELECT 42 AS customer_id, 'Grace' AS Name;"); -``` +Não use FluentMap como ORM, framework CRUD, query builder, unit of work ou abstração de banco. -Isso permite que testes usem mappings diferentes para o mesmo tipo de entidade -no mesmo processo. Testes que exercitam `FluentMapper.Initialize(...)`, -mutação direta de `FluentMapper.EntityMaps`, type maps normais do Dapper ou -Dommel ainda tocam estado process-wide e devem continuar isolados de acordo. +## Instalação -## Dependency Injection +Instale o pacote que corresponde ao recurso necessário: -Instale `Dapper.FluentMap.DependencyInjection` ao usar ASP.NET Core, Worker -Services ou generic host: +| Pacote | Finalidade | +| --- | --- | +| `Dapper.FluentMap` | API core de mapping e integração com Dapper. | +| `Dapper.FluentMap.Dommel` | Integração opcional com Dommel para tabela, chave e colunas geradas. | +| `Dapper.FluentMap.DependencyInjection` | Integração opcional com `Microsoft.Extensions.DependencyInjection`. | +| `Dapper.FluentMap.Analyzers` | Analyzers Roslyn para erros de configuração prováveis em tempo de compilação. | +| `Dapper.FluentMap.Generators` | Source generator para registro de maps e materializadores gerados. | ```bash -dotnet add package Dapper.FluentMap.DependencyInjection +dotnet add package Dapper.FluentMap ``` -Registre o FluentMap na composição de serviços: +Os pacotes públicos targetam `netstandard2.0`. Consulte [COMPATIBILITY.md](COMPATIBILITY.md) antes de adotar um release candidate. + +## Início Rápido ```csharp -using Microsoft.Extensions.DependencyInjection; +using Dapper; +using Dapper.FluentMap; +using Dapper.FluentMap.Mapping; -services.AddFluentMap(builder => +public sealed class Customer { - builder.AddMap(); - builder.Configure(config => config.AddGeneratedMappings()); + 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(); }); + +var customer = connection.QuerySingle( + "SELECT 7 AS customer_id, 'Ada' AS Name;"); ``` -`AddFluentMap(...)` constrói e valida a configuração imutável imediatamente, e -registra `ImmutableFluentMapConfiguration` e `FluentMapRuntime` como singletons. -Use o runtime resolvido com as APIs de query por configuração: +Chame `FluentMapper.Initialize(...)` no startup e trate a configuração global efetiva como somente leitura depois que as queries começarem. -```csharp -public sealed class CustomerReader -{ - private readonly FluentMapRuntime _runtime; +## Mapeamento - public CustomerReader(FluentMapRuntime runtime) - { - _runtime = runtime; - } +Crie maps herdando de `EntityMap`: - public Customer Read(IDbConnection connection) +```csharp +public sealed class ProductMap : EntityMap +{ + public ProductMap() { - return _runtime.QueryMappedSingle( - connection, - "SELECT 7 AS customer_id, 'Ada' AS name;"); + Map(product => product.Id).ToColumn("product_id"); + Map(product => product.Name).ToColumn("product_name", caseSensitive: false); + Map(product => product.TransientValue).Ignore(); } } ``` -O pacote de DI não registra conexões, repositories, bridges Dommel ou type maps -globais do Dapper. Use registro explícito ou gerado para aplicações com -trimming e Native AOT; assembly scanning continua disponível, mas não é o -caminho recomendado em DI para esses deployments. - -## Convenções e Políticas de Nomenclatura +Mappings explícitos têm precedência sobre convenções. Membros raiz não mapeados usam o comportamento normal do Dapper. -Convenções permitem mapear padrões repetidos de colunas: +Convenções e políticas de nomenclatura cobrem padrões repetidos: ```csharp using Dapper.FluentMap.Conventions; +using Dapper.FluentMap.Naming; public sealed class PrefixConvention : Convention { public PrefixConvention() { - Properties() - .Configure(property => property.HasPrefix("col")); + Properties().Configure(property => property.HasPrefix("col")); } } FluentMapper.Initialize(config => { - config.AddConvention() - .ForEntity(); -}); -``` - -Políticas de nomenclatura cobrem transformações comuns: - -```csharp -using Dapper.FluentMap.Naming; - -FluentMapper.Initialize(config => -{ + config.AddConvention().ForEntity(); config.UseNamingPolicy(NamingPolicy.SnakeCase, caseSensitive: false) - .ForEntity(); + .ForEntity(); }); ``` -As políticas disponíveis incluem `Identity`, `SnakeCase`, `Prefix(...)`, `Suffix(...)`, `Custom(...)` e composição com `Then(...)`, `WithPrefix(...)` e `WithSuffix(...)`. +As políticas disponíveis incluem `Identity`, `SnakeCase`, `Prefix(...)`, `Suffix(...)`, `Custom(...)`, `Then(...)`, `WithPrefix(...)` e `WithSuffix(...)`. -## Tipos Imutáveis e Constructor Mapping +## Tipos Imutáveis -FluentMap participa do constructor mapping do Dapper para mapeamentos explícitos no nível raiz: +FluentMap participa do constructor mapping do Dapper para mappings explícitos no nível raiz: ```csharp public sealed class Customer @@ -1414,7 +581,6 @@ public sealed class Customer } public int Id { get; } - public string FullName { get; } } @@ -1428,9 +594,9 @@ public sealed class CustomerMap : EntityMap } ``` -Quando você precisa que o FluentMap construa objetos aninhados imutáveis ou Value Objects, use `QueryMapped*`. +Use `QueryMapped*` quando o FluentMap precisar construir objetos aninhados imutáveis ou value objects. -## Mapeamento de Objetos Aninhados +## Objetos Aninhados Caminhos aninhados usam a mesma API `Map(...)`: @@ -1443,26 +609,22 @@ public sealed class CustomerMap : EntityMap Map(customer => customer.Address.City).ToColumn("city"); } } -``` -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;"); ``` -`QueryMapped*` cria objetos intermediários suportados, preserva semântica de null em subárvores aninhadas e rejeita caminhos não suportados com `FluentMapConfigurationException`. +Materialização aninhada é opt-in via `QueryMapped*`, `ReadMapped*`, `QueryMultipleMapped` e helpers de streaming. `Dapper.Query()` normal continua usando materialização raiz do Dapper. ## Value Objects -Para Value Objects escalares mapeados como uma propriedade inteira, prefira um `TypeHandler` do Dapper: +Para value objects escalares mapeados como um único valor de banco, prefira um `TypeHandler` do Dapper: ```csharp Map(customer => customer.Cpf).ToColumn("cpf"); ``` -Para Value Objects mapeados pelos seus componentes, `QueryMapped*` pode construí-los por construtores públicos compatíveis: +Para value objects mapeados por componentes, a materialização controlada pelo FluentMap pode chamar construtores públicos compatíveis: ```csharp public sealed class CustomerMap : EntityMap @@ -1478,11 +640,11 @@ var customer = connection.QueryMappedSingle( "SELECT 1 AS customer_id, '12345678909' AS cpf;"); ``` -Factory methods não são usadas pelo materializador de runtime atual. +Factory methods não são usadas pelo materializador atual. -## Mapping Profiles +## Profiles -Profiles são mapeamentos opt-in para a mesma entidade em formatos SQL diferentes: +Profiles são mappings opt-in para a mesma entidade em formatos SQL diferentes: ```csharp using Dapper.FluentMap.Mapping; @@ -1512,43 +674,17 @@ var legacy = connection.QueryMappedSingle( "SELECT 7 AS id, 'Legacy Ltd.' AS legal_name;"); ``` -Profiles são selecionados por operação com `QueryMapped()`. Eles não substituem o type map global do Dapper para a entidade. +Profiles são selecionados por query controlada pelo FluentMap. Eles não substituem o type map global do Dapper para a entidade. -Profiles também podem ser selecionados por result set em multiplos resultados mapeados: +## Materialização Gerada -```csharp -using var multi = connection.QueryMultipleMapped(sql); - -var currentCustomers = multi.ReadMapped(); -var legacyCustomers = multi.ReadMapped(); -``` - -Use `ReadMappedSingle()` ou `ReadMappedSingle()` quando o result set atual deve conter exatamente uma linha. - -## 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 -var explanation = FluentMapper.Explain(); +Instale `Dapper.FluentMap.Generators` para registro gerado de maps da compilação atual: -foreach (var member in explanation.Members) -{ - Console.WriteLine($"{member.MemberPath} -> {member.ColumnName} ({member.Source})"); -} +```bash +dotnet add package Dapper.FluentMap.Generators ``` -## 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()`: +Depois chame a extensão gerada: ```csharp FluentMapper.Initialize(config => @@ -1557,92 +693,54 @@ FluentMapper.Initialize(config => }); ``` -O registro gerado chama os caminhos existentes `AddMap()` / `AddProfile()`. Para maps explícitos com colunas literais e construção determinística suportada, ele também registra materializadores de linha gerados para o shape ordenado de colunas correspondente, incluindo propriedades flat, caminhos aninhados, Value Objects construídos por construtor e property read converters suportados estaticamente. Maps não suportados e shapes inesperados continuam usando o fallback runtime. Ele não escaneia assemblies referenciados, não executa construtores de maps durante a geração e não substitui `FluentMapper.Validate()`. - -O runtime principal também expõe contratos de baixo nível para registro de materializadores gerados por código emitido por generator. Esses contratos são infraestrutura aditiva; consumidores atuais não precisam registrar materializadores manualmente, e a ausência de materializadores gerados continua usando o fallback runtime existente. - -## Trimming / Native AOT +O generator emite chamadas `AddMap()` e `AddProfile()` para maps elegíveis. Para mappings explícitos suportados, ele também pode registrar materializadores de linha gerados para o shape ordenado de colunas, incluindo propriedades simples, caminhos aninhados, value objects construídos por construtor e read converters suportados estaticamente. -FluentMap tem níveis diferentes de suporte conforme a API: +Materialização gerada é otimização. Maps não suportados, shapes dinâmicos, divergências de shape, converters por instância/delegate e alguns padrões avançados usam fallback runtime. -| Á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. | +## Semântica de Persistência -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. `QueryMapped*` mantém suas anotações de trimming e dynamic code mesmo quando um materializador gerado existe, porque shapes não suportados ainda podem cair para o materializador runtime. - -## 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: +Metadata de persistência descreve participação em escrita sem mudar materialização de leitura: ```csharp -connection.Query(sql); -connection.QuerySingle(sql); -``` - -Use os helpers de consulta do FluentMap quando precisar de materialização avançada controlada pelo FluentMap: +Map(product => product.CreatedAt) + .ToColumn("created_at") + .DatabaseDefaultOnInsert(); -```csharp -connection.QueryMapped(sql); -connection.QueryMappedSingle(sql); -connection.QueryMappedSingle(sql); -connection.QueryMappedUnbuffered(sql); -connection.QueryMappedUnbufferedAsync(sql, cancellationToken); +Map(product => product.UpdatedAt) + .ToColumn("updated_at") + .ReadOnly(); -using var multi = connection.QueryMultipleMapped(sql); -var customers = multi.ReadMapped(); -var orders = multi.ReadMapped(); +Map(product => product.Total) + .ToColumn("total") + .Computed(); ``` -`QueryMapped*` e `ReadMapped*` retornam resultados bufferizados e são os caminhos que suportam materialização de objetos aninhados, Value Objects construídos por construtor e mapeamento específico por profile. Quando existe materializador gerado para entidade, profile e shape ordenado de colunas, essas APIs o utilizam; caso contrário, usam o fallback de materialização em runtime. - -Para configuracoes independentes no mesmo processo, crie um runtime a partir de -uma configuracao imutavel e use seus entry points de consulta: - -```csharp -var configuration = new FluentMapConfigurationBuilder() - .AddMap() - .Build(); +`Ignore()` mantém o significado histórico: a propriedade não é materializada pelo FluentMap e não participa da metadata de persistência gerada. Para valores de banco que ainda devem ser selecionados, mas não escritos, use `ReadOnly()`, `Computed()`, `DatabaseDefaultOnInsert()`, `ExcludeFromInsert()` ou `ExcludeFromUpdate()`. -var runtime = configuration.CreateRuntime(); -var customers = runtime.QueryMapped( - connection, - "SELECT 7 AS customer_id, 'Ada' AS Name;"); -``` +O pacote core armazena metadata. Dommel é o pacote atual que a consome para comportamento de `INSERT` e `UPDATE` gerados. -O runtime possui caches escopados por configuracao para lookup de mapping, -lookup de materializador gerado e planos de materializacao runtime. Ele e seguro -para compartilhamento entre queries concorrentes quando a configuracao e -imutavel. +## QueryMultiple / Streaming -Use `QueryMultipleMapped(...)` quando um comando retorna múltiplos result sets que precisam de materialização controlada pelo FluentMap: +Use os helpers de query do FluentMap quando a materialização precisa honrar nested mappings, value objects, profiles, converters ou materializers gerados: ```csharp -var sql = @" - SELECT 1 AS customer_id, 'Ada' AS customer_name; - SELECT 10 AS order_id, 42.50 AS total;"; - -using var multi = connection.QueryMultipleMapped(sql); - -var customers = multi.ReadMapped().ToList(); -var orders = multi.ReadMapped().ToList(); +var customers = connection.QueryMapped(sql); +var customer = connection.QueryMappedSingle(sql); +var legacy = connection.QueryMappedSingle(legacySql); ``` -Os result sets são consumidos sequencialmente. `ReadMapped()` e `ReadMapped()` bufferizam o result set atual, avançam para o próximo e mantêm o reader subjacente aberto até todos os result sets serem consumidos ou até o `MappedGridReader` ser descartado. - -Profiles podem ser selecionados por result set: +Para múltiplos result sets: ```csharp using var multi = connection.QueryMultipleMapped(sql); -var currentCustomers = multi.ReadMapped(); -var legacyCustomers = multi.ReadMapped(); +var customers = multi.ReadMapped(); +var orders = multi.ReadMapped(); ``` -Use `QueryMappedUnbuffered()` ou `QueryMappedUnbuffered()` quando precisar processar um result set grande de forma incremental: +`ReadMapped*` consome result sets em sequência e bufferiza o result set atual. + +Para processamento incremental: ```csharp foreach (var customer in connection.QueryMappedUnbuffered(sql)) @@ -1651,117 +749,148 @@ foreach (var customer in connection.QueryMappedUnbuffered(sql)) } ``` -Consultas unbuffered são lazy: o comando é executado quando a enumeração começa, não quando o método é chamado. O reader subjacente permanece aberto até a enumeração terminar ou o enumerator ser descartado. Se o FluentMap abrir uma conexão fechada para a enumeração, o dispose do reader fecha a conexão novamente; se a conexão já estava aberta, ela permanece aberta e precisa continuar válida durante toda a enumeração. Descarte o enumerator, por exemplo usando `foreach`, ao parar cedo. - -Use `QueryMappedUnbufferedAsync()` ou `QueryMappedUnbufferedAsync()` em `DbConnection` quando o provider suportar readers assíncronos: +Streaming assíncrono está disponível em `DbConnection`: ```csharp -using var cancellation = new CancellationTokenSource(); - await foreach (var customer in connection.QueryMappedUnbufferedAsync( sql, - cancellation.Token)) + cancellationToken)) { - await ProcessAsync(customer, cancellation.Token); + await ProcessAsync(customer, cancellationToken); } ``` -Consultas async unbuffered também são lazy e incrementais. O FluentMap aguarda a execução do comando e `DbDataReader.ReadAsync(...)`, propaga cancellation para operações async suportadas e descarta o reader quando a enumeração termina, para cedo, é cancelada ou falha. A materialização da linha continua síncrona depois que a linha foi lida; materializers gerados e fallback runtime usam o mesmo dispatch dos caminhos buffered e unbuffered síncrono. - -`QueryMultipleMapped` trata de múltiplos result sets, não de Dapper multi-mapping com `splitOn`. O FluentMap não faz agregação de grafo, identity map nem agrupamento automático de joins; escreva o shape SQL necessário e use o helper mapeado apenas quando o FluentMap deve materializar cada linha. - -## Dommel - -Instale `Dapper.FluentMap.Dommel` ao usar [Dommel](https://github.com/henkmollema/Dommel): +Streaming mantém o reader subjacente aberto até a enumeração terminar ou o enumerator ser descartado. -```bash -dotnet add package Dapper.FluentMap.Dommel -``` +## Conversores de Propriedade -Crie maps com `DommelEntityMap` quando precisar de metadados específicos do Dommel para tabela e chave: +Conversores de propriedade são configurados por propriedade mapeada e executam somente na materialização controlada pelo FluentMap: ```csharp -using Dapper.FluentMap.Dommel.Mapping; -using Dapper.FluentMap.Dommel; - -public sealed class ProductMap : DommelEntityMap +public sealed class ProductMap : EntityMap { public ProductMap() { - ToTable("products"); - Map(product => product.Id).ToColumn("product_id").IsKey().IsIdentity(); + Map(product => product.Status) + .ToColumn("status_code") + .ConvertFromDatabaseUsing(); + } +} + +public sealed class ProductStatusConverter : + IReadPropertyConverter +{ + public ProductStatus ConvertFromDatabase(string value) + { + return value == "A" ? ProductStatus.Active : ProductStatus.Inactive; } } ``` -Ative a integração com Dommel durante a configuração do FluentMap: +A precedência de conversão de leitura na materialização controlada pelo FluentMap é: + +```text +tratamento de null/DBNull + -> read converter da propriedade + -> Dapper TypeHandler + -> conversão default do FluentMap +``` + +Metadata de write converter pode ser configurada, mas não é executada atualmente por escritas Dapper ou Dommel. + +## Configuração Isolada / DI + +A API estática histórica continua suportada: ```csharp FluentMapper.Initialize(config => { - config.AddMap(new ProductMap()); - config.ForDommel(); + config.AddMap(); }); ``` -A integração Dommel respeita a metadata de persistência em comandos `INSERT` e -`UPDATE` gerados. `ReadOnly()` e `Computed()` são selecionados, mas não escritos; -`DatabaseDefaultOnInsert()` e `ExcludeFromInsert()` são omitidos do `INSERT` e -continuam atualizáveis; `ExcludeFromUpdate()` continua inserível, mas não é -escrito pelo `UPDATE`. Esses comportamentos são metadata no pacote core; o -Dommel é o pacote que os transforma em comportamento de SQL gerado. +Para múltiplas configurações controladas pelo FluentMap no mesmo processo, crie configurações imutáveis e use seus runtimes: + +```csharp +using Dapper.FluentMap.Configuration; + +var runtime = new FluentMapConfigurationBuilder() + .AddMap() + .Build() + .CreateRuntime(); + +var customer = runtime.QueryMappedSingle( + connection, + "SELECT 7 AS customer_id, 'Ada' AS Name;"); +``` -Metadata de chave é específica do Dommel: +Instale `Dapper.FluentMap.DependencyInjection` para registro em DI: ```csharp -Map(product => product.Id) - .ToColumn("product_id") - .IsKey() - .IsIdentity(); - -Map(product => product.Code) - .ToColumn("product_code") - .IsKey() - .SetGeneratedOption(DatabaseGeneratedOption.None); +using Microsoft.Extensions.DependencyInjection; + +services.AddFluentMap(builder => +{ + builder.AddMap(); + builder.Configure(config => config.AddGeneratedMappings()); +}); ``` -`IsKey()` identifica a linha. `IsIdentity()` marca uma identity gerada pelo banco, -excluída de `INSERT` e do `SET` de `UPDATE`. Uma key non-identity é atribuída -pela aplicação, participa do `INSERT` e é usada pelo Dommel no `WHERE` do -`UPDATE`, não no `SET`. +O pacote de DI registra `ImmutableFluentMapConfiguration` e `FluentMapRuntime` como singletons. Ele não registra conexões de banco, repositories, bridges Dommel ou type maps globais do Dapper. + +## AOT / Trimming + +FluentMap tem prontidão parcial para trimming/AOT, não compatibilidade Native AOT completa: -### Notas de Compatibilidade +| Área | Status | +| --- | --- | +| Registro explícito com `AddMap()` | Preferencial para cenários com trimming e Native AOT. | +| Registro gerado com `AddGeneratedMappings()` | Alternativa preferencial ao assembly scanning para maps da compilação atual. | +| Assembly scanning | Baseado em reflection e anotado como sensível a trimming. | +| `QueryMapped*`, `ReadMapped*`, `QueryMultipleMapped`, streaming | Anotados como sensíveis a trimming/dynamic code porque fallback runtime pode ocorrer. | -Código FluentMap histórico às vezes usava `Ignore()` para remover uma -propriedade do `INSERT` ou `UPDATE` do Dommel. Mantenha `Ignore()` apenas para -valores que não devem ser materializados. Para valores gerados pelo banco que -ainda devem ser lidos, use o persistence behavior correspondente: -`ReadOnly()`, `Computed()`, `DatabaseDefaultOnInsert()`, `ExcludeFromInsert()` ou -`ExcludeFromUpdate()`. +Não trate o pacote como totalmente seguro para Native AOT sem validar o caminho de query e o modo de publicação exatos da sua aplicação. + +## Compatibilidade + +A documentação atual de compatibilidade está em [COMPATIBILITY.md](COMPATIBILITY.md). + +Resumo: + +- pacotes públicos targetam `netstandard2.0`; +- testes rodam atualmente em `net10.0`; +- a faixa de Dapper é `[2.1.79,3.0.0)`, com `2.1.79` validado na matriz atual; +- a faixa de Dommel é `[3.5.3,4.0.0)` no pacote opcional Dommel; +- SQLite é validado por testes automatizados de provider; +- SQL Server e PostgreSQL têm harness condicional, mas ainda não são certificados em CI; +- MySQL/MariaDB não está validado; +- SQL Server CE permanece legado/limitado por upstream. + +Para migrar do FluentMap histórico, consulte [MIGRATION.md](MIGRATION.md). ## Limitações Atuais -- `FluentMapper.Initialize(...)`, `Dapper.Query()` e Dommel continuam usando bridges globais/process-wide. Para multiplas configuracoes simultaneas no mesmo processo, use `ImmutableFluentMapConfiguration` + `FluentMapRuntime` com os entry points `QueryMapped*`/`ReadMapped*`. -- Múltiplas configurações são isoladas somente para materialização controlada pelo FluentMap. `Dapper.Query()` normal usa o `SqlMapper.SetTypeMap` global registrado para o tipo de entidade. -- A integração Dommel usa resolvers/builders globais do `DommelMapper` e lê as coleções legadas process-wide do FluentMap; runtimes isolados do core não configuram o Dommel. -- Assembly scanning depende de descoberta por reflection e não é o caminho recomendado para aplicações com trimming ou Native AOT. -- Property converters nao sao object mapper geral, serializer, hook de SQL nem substituto para `TypeHandler` do Dapper. -- Write converters sao metadata-only na integracao Dommel atual e nao sao executados por `Insert` ou `Update`. -- Overloads por tipo de converter exigem construtor publico parameterless; overloads por instancia e delegate sao as formas preferidas de configuracao runtime quando o converter precisa de construcao explicita. -- Conversao de leitura gerada suporta converter types visiveis estaticamente; instancias, delegates, converter types inacessiveis e padroes fluent nao suportados usam fallback runtime. -- `QueryMapped*` pode usar materializadores gerados para shapes flat, aninhados e Value Object suportados, mas ainda pode cair para metadados de runtime e código dinâmico; ele ainda não é um caminho de materialização garantidamente seguro para Native AOT. -- Mapping profiles são selecionados pelas APIs `QueryMapped()` e `ReadMapped()`. -- `QueryMapped*` e `ReadMapped*` são bufferizados. Use `QueryMappedUnbuffered*` para streaming unbuffered síncrono ou assíncrono explícito. -- `QueryMultipleMapped` consome result sets sequencialmente e não suporta leituras concorrentes do mesmo `MappedGridReader`. -- Streaming mantém o reader subjacente aberto. Não use a mesma conexão concorrentemente enquanto um reader estiver ativo, salvo quando o provider suportar explicitamente esse uso. -- Múltiplos result sets não são Dapper multi-mapping por `splitOn`; o FluentMap não faz agregação de grafo nem agrupamento automático de joins. -- A construção de Value Objects usa construtores públicos compatíveis, não factory methods. +- `FluentMapper.Initialize(...)`, `Dapper.Query()` normal e integrações Dommel usam estado global process-wide. +- Runtimes isolados se aplicam à materialização controlada pelo FluentMap, não a queries Dapper normais nem Dommel. +- Dommel usa resolvers/builders globais do `DommelMapper`. +- `QueryMultipleMapped` é sequencial e bufferizado por result set; não há `QueryMultipleMappedAsync`. +- `QueryMultipleMapped` não é multi-mapping do Dapper com `splitOn`. +- FluentMap não agrega linhas de joins em grafos e não mantém identity map. +- Write converters são apenas metadata no caminho atual de escrita Dapper/Dommel. +- Materializers gerados cobrem um subconjunto suportado e podem cair para materialização runtime. +- Assembly scanning e fallback runtime são sensíveis a trimming/AOT. +- Construção de value objects usa construtores públicos compatíveis, não factory methods. -## Contribuição +## Mais Documentaçã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. +- [MIGRATION.md](MIGRATION.md) +- [COMPATIBILITY.md](COMPATIBILITY.md) +- [SUPPORT.md](SUPPORT.md) +- [CHANGELOG.md](CHANGELOG.md) + +## Contribuição -Validação típica: +Mantenha mudanças pequenas, compatíveis com a API pública e cobertas por testes focados. Validação local típica: ```bash dotnet restore ./Dapper.FluentMap.sln diff --git a/SUPPORT.md b/SUPPORT.md new file mode 100644 index 0000000..ac100da --- /dev/null +++ b/SUPPORT.md @@ -0,0 +1,73 @@ +# Support Policy + +This project is maintained as an open source .NET library. The policy is intentionally small and sustainable. + +## Supported Versions + +The supported line is the current fork line once a release candidate or stable release is published from this repository. + +| Line | Support status | +| --- | --- | +| Current fork prerelease/RC | Supported for adoption feedback, bug reports and compatibility validation. Preview behavior may still change before stable. | +| Current fork stable | Supported for compatible bug fixes and security fixes once published. | +| Historical archived `Dapper.FluentMap` packages | Not actively maintained by this fork, except where compatibility is explicitly preserved or migration guidance is provided. | + +Do not publish or depend on an unreleased local package as if it were a stable support line. + +## Bug Fixes + +Bug fixes should preserve source, binary and behavioral compatibility unless the existing behavior is clearly incorrect and the change is documented as a bug fix. + +Fixes should include focused tests when practical, especially for: + +- public mapping behavior; +- Dapper integration; +- Dommel integration; +- generated materialization; +- provider compatibility; +- global-state or cache behavior. + +## Security Fixes + +Security issues are prioritized over ordinary bugs. The project does not promise an SLA, but security reports should include enough detail to reproduce or evaluate the issue. + +If private GitHub security advisories are enabled for the repository, use that channel. Otherwise, open an issue with minimal sensitive detail and request a private follow-up path. + +## Preview And RC Behavior + +Release candidates are intended to validate package shape, API compatibility, migration guidance and real consumer adoption before a stable release. + +During RC: + +- new APIs may still receive naming or documentation adjustments; +- analyzer severities and generator diagnostics may still be tuned; +- compatibility gaps may block stable promotion; +- unsupported claims should stay documented rather than implied. + +Breaking changes after a stable release require an explicit major-version decision. + +## Unsupported Environments + +The project currently does not support or certify: + +- full Native AOT compatibility; +- Dapper `3.x` or later; +- Dommel `4.x` or later; +- provider certification without real automated or documented integration tests; +- Dommel isolation per `FluentMapRuntime`; +- using FluentMap as an ORM, CRUD framework, query builder, migration tool or connection abstraction. + +## Issue Reporting + +Good issues include: + +- package name and version; +- .NET runtime/SDK version; +- Dapper and Dommel versions when relevant; +- database provider and version when provider behavior matters; +- a minimal entity/map/query example; +- expected behavior; +- actual behavior; +- whether the issue happens with normal `Dapper.Query()`, FluentMap `QueryMapped*`, Dommel, generated registration or DI. + +For provider issues, include whether the provider is SQLite, SQL Server, PostgreSQL, MySQL/MariaDB or another provider, and whether the failure reproduces outside FluentMap. From 15e926ccf83801ad3b40b2e6afb491f63e98d189 Mon Sep 17 00:00:00 2001 From: Rodrigo de Oliveira Date: Wed, 29 Jul 2026 13:30:06 -0300 Subject: [PATCH 42/49] chore(release): complete FluentMap readiness audit --- .sdd/ROADMAP-SUMMARY.md | 85 ++++++++ .sdd/etapa-12/FINAL-REPORT.md | 372 ++++++++++++++++++++++++++++++++++ .sdd/etapa-12/STATUS.md | 111 +++++++--- 3 files changed, 543 insertions(+), 25 deletions(-) create mode 100644 .sdd/ROADMAP-SUMMARY.md create mode 100644 .sdd/etapa-12/FINAL-REPORT.md diff --git a/.sdd/ROADMAP-SUMMARY.md b/.sdd/ROADMAP-SUMMARY.md new file mode 100644 index 0000000..f980b92 --- /dev/null +++ b/.sdd/ROADMAP-SUMMARY.md @@ -0,0 +1,85 @@ +# Roadmap Summary + +Resumo das Etapas 7-12. Este documento consolida resultado e limitacoes, sem +substituir os final reports de cada etapa. + +## Etapa 7 - Generated Materialization & Performance + +Objetivo: adicionar contratos e integracao para materializadores gerados, +preservando runtime fallback e medindo impacto de alocacao/performance. + +Resultado: concluida. Foram adicionados descriptors de generated materializer, +registro gerado, dispatch por shape ordenado, cobertura para flat, immutable, +nested, value objects, profiles e ignored properties. Benchmarks mostraram +reducao de alocacao no hot path gerado. + +Limitacoes restantes: generated cobre subconjunto estatico; `IncludeBase`, +conventions dinamicas, TypeHandler no generated path e full Native AOT foram +adiados. + +## Etapa 8 - Persistence Semantics & Historical Compatibility + +Objetivo: separar semantica de leitura/escrita sem transformar o core em CRUD e +resolver regressoes historicas ligadas a Dommel/persistence. + +Resultado: concluida. O core ganhou metadata de persistencia, APIs fluent como +`ReadOnly()`, `Computed()` e `DatabaseDefaultOnInsert()`, diagnostics e +integracao Dommel para writes gerados. + +Limitacoes restantes: write SQL continua fora do core; Dommel permanece global; +builders customizados precisam respeitar metadata; provider-specific coverage +alem de SQLite ficou para matriz futura. + +## Etapa 9 - Advanced Query Materialization + +Objetivo: ampliar os caminhos opt-in de materializacao com QueryMultiple, +streaming sincronico, streaming assincrono e cancellation. + +Resultado: concluida. Foram adicionados `QueryMultipleMapped`, `MappedGridReader`, +`ReadMapped*`, `QueryMappedUnbuffered*` e async streaming por `DbConnection`. +Generated/runtime fallback compartilham o mesmo dispatch. + +Limitacoes restantes: nao ha `QueryMultipleMappedAsync`, streaming por grid em +`MappedGridReader`, Dapper multi-mapping por `splitOn`, graph aggregation ou +identity map. + +## Etapa 10 - Property Conversion & Extensibility + +Objetivo: adicionar conversores por propriedade e por direcao, mantendo +interoperabilidade com Dapper TypeHandlers e sem prometer write conversion ainda. + +Resultado: concluida. Foram adicionados contratos read/write/bidirecionais, +delegates, metadata publica, runtime read conversion, generated read conversion +quando suportado e diagnostics/analyzers correspondentes. + +Limitacoes restantes: write converters sao metadata-only; converters por +instancia/delegate usam fallback generated; TypeHandler no generated path segue +adiado; nao ha factory/DI/scoped converter lifetime. + +## Etapa 11 - Configuration Isolation & DI + +Objetivo: permitir configuracoes isoladas para materializacao controlada pelo +FluentMap e adicionar integracao opcional com DI sem quebrar a API estatica. + +Resultado: concluida. Foram adicionados `FluentMapConfigurationBuilder`, +`ImmutableFluentMapConfiguration`, `FluentMapRuntime`, caches por runtime, +bridge estatica compativel e pacote `Dapper.FluentMap.DependencyInjection`. + +Limitacoes restantes: `Dapper.Query()` e Dommel continuam process-wide; +colecoes legadas permanecem mutaveis por compatibilidade; named/keyed DI e +Dommel isolation foram adiados. + +## Etapa 12 - Compatibility, Hardening & Release Readiness + +Objetivo: auditar compatibilidade, providers, API publica, pacotes, CI, +documentacao, supply chain, trimming/AOT e prontidao de release. + +Resultado: concluida com recomendacao de Release Candidate, nao stable. A +solution passou restore/build/test, packages foram gerados, SQLite foi validado, +NuGet metadata foi endurecida, CI/release workflow foram preparados, +vulnerability audit passou e documentacao publica foi consolidada. + +Limitacoes restantes: nao publicar `2.0.0`; criar baseline API/binaria do fork; +validar SourceLink no remoto; revisar analyzer/generator release manifests; +decidir SBOM/signing; certificar SQL Server/PostgreSQL apenas com infraestrutura +real; full Native AOT nao e claim suportado. diff --git a/.sdd/etapa-12/FINAL-REPORT.md b/.sdd/etapa-12/FINAL-REPORT.md new file mode 100644 index 0000000..a9ef136 --- /dev/null +++ b/.sdd/etapa-12/FINAL-REPORT.md @@ -0,0 +1,372 @@ +# Etapa 12 - Final Report + +## Executive Summary + +Auditoria final executada em 2026-07-29 no checkout local da branch +`feature/etapa-3`. O FluentMap esta buildable, testable, packable e +documentado para uma primeira validacao publica como Release Candidate do fork. + +Conclusao objetiva: o projeto esta pronto para preparar uma RC, preferencialmente +`3.0.0-rc.1`, mas nao esta pronto para release stable. Tambem nao deve ser +publicado com a versao padrao atual `2.0.0`, pois esse numero ja existe nos +package IDs historicos `Dapper.FluentMap` e `Dapper.FluentMap.Dommel`. + +## Release Recommendation + +Recomendacao: `Release Candidate`. + +Nao recomendado: `Stable`. + +Racional: + +- restore, build, test, pack, provider SQLite, vulnerability audit, trimmed + smokes e benchmark smoke passaram localmente; +- a evolucao das Etapas 7-12 e grande demais para stable direto sem usuarios + reais na nova arquitetura; +- ha uma quebra historica conhecida no pacote Dommel em relacao ao `2.0.0` + original (`DommelPropertyMap.GeneratedOption` nullable); +- baseline API/binaria do proprio fork ainda deve ser estabelecida apos a + primeira RC; +- Native AOT completo, SQL Server/PostgreSQL certificados, SourceLink checksum + remoto, package signing e SBOM formal ainda nao estao fechados. + +## Scope Reviewed + +- `README.md`, `MIGRATION.md`, `COMPATIBILITY.md`, `SUPPORT.md` e `CHANGELOG.md`. +- `.sdd/etapa-12/01-07`, `DECISIONS.md` e `STATUS.md`. +- Final reports das etapas 7, 8, 9, 10 e 11. +- Solution, projetos publicos, testes, provider tests, AOT smoke, benchmarks, + NuGet configuration e workflows GitHub. +- Public APIs documentadas dos cinco pacotes. +- Conteudo e metadata dos pacotes gerados. + +## Full SDD Audit + +| Requirement | Evidence | Status | Blocker? | +| ----------- | -------- | ------ | -------- | +| Preservar `netstandard2.0` nos pacotes publicos | Todos os projetos publicos targetam `netstandard2.0`; build Release passou | Passed | No | +| Testar em runtime moderno sem elevar TFM minimo | Projetos de teste/smoke/benchmark em `net10.0`; SDK `10.0.302` | Passed | No | +| Validar Dapper minimo e latest stable suportado | NuGet.org consultado: latest stable `Dapper 2.1.79`; solution passou com a faixa atual | Passed | No | +| Declarar range conservador de Dapper | Nuspec core/Dommel: `Dapper [2.1.79, 3.0.0)` | Passed | No | +| Cobrir boundary sensivel de TypeHandler | Testes de TypeHandler passam; codigo usa reflection sobre `TypeHandlerCache.Parse` | Passed with limitation | No para RC; risco para stable | +| Validar Dommel atual | Dommel tests: 23 passed; range `Dommel [3.5.3,4.0.0)` no pacote | Passed | No | +| Separar support de provider certification | `COMPATIBILITY.md` e provider matrix diferenciam SQLite, SQL Server, PostgreSQL, MySQL e SQL CE | Passed | No | +| SQLite provider real | Provider tests: 7 passed em SQLite | Passed | No | +| SQL Server/PostgreSQL reais | Harness existe, mas env vars ausentes e 14 testes skipped | Passed with limitation | No para RC; blocker para claim certificado | +| MySQL/MariaDB | Sem harness obrigatorio; documentado como not validated | Deferred intentionally | No | +| SQL Server CE | Builder legado mantido, validacao moderna limitada por upstream | Not applicable | No | +| API publica revisada | `.sdd/etapa-12/05-public-api-review.md`; build/package validation | Passed with limitation | Stable blocker ate baseline do fork | +| Binary compatibility formal | Package validation habilitada; baseline do fork ainda inexistente | Passed with limitation | Stable blocker | +| Versionamento publicavel | Pack padrao gera `2.0.0`; pack override `3.0.0-rc.1` passou | Passed with limitation | Blocker se publicar `2.0.0` | +| NuGet metadata moderna | README, license expression, repository URL/commit e dependency ranges nos nuspecs | Passed | No | +| SourceLink/symbols | `.snupkg` runtime gerados; PDB contem raw GitHub URL do commit | Passed with limitation | Validar checksum no CI apos push | +| Analyzer/generator package layout | Assemblies/PDBs em `analyzers/dotnet/cs`; sem deps Roslyn transitivas no nuspec | Passed | No | +| Analyzer/generator release manifests | Arquivos existem, mas precisam promocao/revisao antes de stable | Passed with limitation | Stable blocker | +| CI hardening | Actions pinadas por SHA, permissoes minimas, release workflow sem publish | Passed | No | +| Publish NuGet | Intencionalmente desabilitado | Deferred intentionally | No | +| Vulnerability audit | `dotnet list ... --vulnerable --include-transitive`: sem vulnerabilidades | Passed | No | +| Secrets no repo/pacotes | Scan textual encontrou apenas docs/testes/workflow OIDC; pacotes inspecionados sem secrets | Passed | No | +| Package signing | `dotnet nuget verify` falha com `NU3004` por pacote nao assinado | Deferred intentionally | No para RC; decisao stable | +| SBOM formal | Dependency inventory/provenance preparados; SPDX/CycloneDX nao adotado | Deferred intentionally | No para RC | +| Trimming smoke | Publish trimmed DI explicit/generated passou e executou | Passed with limitation | No | +| Native AOT publish/run | Bloqueado por linker nativo ausente | Failed | Blocker para claim Native AOT | +| Performance guardrail | BenchmarkDotNet smoke de 20 cenarios passou; sem regressao severa observada | Passed with limitation | No | +| Documentacao publica | README/MIGRATION/COMPATIBILITY/SUPPORT revisados contra APIs/testes | Passed | No | + +## Build + +Executado: + +```bash +dotnet restore ./Dapper.FluentMap.sln +dotnet build ./Dapper.FluentMap.sln --configuration Release --no-restore +``` + +Resultado: restore e build Release passaram com 0 warnings e 0 errors. + +## Tests + +Executado: + +```bash +dotnet test ./Dapper.FluentMap.sln --configuration Release --no-build +``` + +Resultado: 460 passed, 14 skipped, 0 failed. + +Skips: 14 testes condicionais de SQL Server/PostgreSQL por ausencia de +`DFM_SQLSERVER_CONNECTION_STRING` e `DFM_POSTGRESQL_CONNECTION_STRING`. + +## Dapper Compatibility + +NuGet.org foi consultado em 2026-07-29: + +| Package | Latest stable | Latest any | +| --- | ---: | ---: | +| Dapper | `2.1.79` | `2.1.79` | + +Versoes validadas: + +- minimum supported: `2.1.79`; +- latest supported stable: `2.1.79`. + +Range nos pacotes: + +```text +Dapper [2.1.79,3.0.0) +``` + +Risco restante: `DapperTypeHandlerAdapter` acessa +`SqlMapper.TypeHandlerCache.Parse(object)` por reflection. + +## .NET Compatibility + +Validados: + +- public packages: `netstandard2.0` build; +- tests/smoke/benchmarks: `net10.0`; +- SDK local: `10.0.302`, conforme `global.json`. + +Nao foi alterado TFM minimo. + +## Dommel Compatibility + +Versao validada: + +- Dommel `3.5.3`; +- range empacotado: `Dommel [3.5.3,4.0.0)`; +- `Dapper.FluentMap.Dommel.Tests`: 23 passed; +- provider tests cobrem persistencia Dommel em SQLite. + +Limitacao: Dommel permanece process-wide via `DommelMapper`; nao e isolado por +`FluentMapRuntime`. + +## Provider Validation + +| Provider | Resultado | +| --- | --- | +| SQLite | Validated: 7 provider tests passed | +| SQL Server | Harness condicional presente; 7 skipped por env var ausente | +| PostgreSQL | Harness condicional presente; 7 skipped por env var ausente | +| MySQL/MariaDB | Not validated; apenas suporte por builder Dommel existente | +| SQL Server CE | Legacy/upstream-limited | + +## Public API + +Superficie revisada nos pacotes core, Dommel, DependencyInjection, Analyzers e +Generators. APIs historicas principais permanecem; APIs novas sao +majoritariamente aditivas; `DommelPropertyMap.GeneratedOption` e quebra +historica frente ao pacote original `2.0.0`. + +## Binary Compatibility + +Package validation nativa do SDK esta habilitada para core, Dommel e +DependencyInjection. Isso e suficiente como guardrail de RC, mas stable ainda +exige baseline formal do proprio fork. + +## NuGet Packages + +Executado: + +```bash +dotnet pack ./Dapper.FluentMap.sln --configuration Release --no-build --output ./artifacts/packages-12.7-final +dotnet pack ./Dapper.FluentMap.sln --configuration Release --no-build --output ./artifacts/packages-12.7-rc -p:VersionPrefix=3.0.0-rc.1 +``` + +Resultado: + +- pack padrao: sucesso; 5 `.nupkg` e 3 `.snupkg`; +- pack RC override: sucesso; 5 `.nupkg` e 3 `.snupkg`; +- pack padrao ainda gera `2.0.0`, que nao deve ser publicado; +- conteudo dos `.nupkg`: README, assemblies/XML docs em `lib/netstandard2.0` + para runtime packages; analyzers/generators em `analyzers/dotnet/cs`; +- sem test/benchmark/AOT smoke binaries nos pacotes inspecionados. + +Metadata confirmada: license MIT, README, repository URL do fork, repository +branch/commit e ranges Dapper/Dommel esperados. + +## SourceLink and Symbols + +- `.snupkg` gerados para core, Dommel e DependencyInjection. +- Analyzer/generator incluem PDB no pacote principal. +- PDB do core contem SourceLink para o commit local + `432705c118e697d4f51fecede1c1682d3d3f66fc`. +- `sourcelink` nao esta instalado localmente; checksum/download remoto deve + ser validado em CI apos push. +- `dotnet nuget verify` confirmou hashes, mas falhou com `NU3004` porque os + pacotes nao estao assinados. + +## Generated Materialization + +Etapa 7 nao foi regredida: generated registration tests passaram, os smokes +`generated:ok` e `di-generated:ok` passaram, e o benchmark smoke preserva o +perfil esperado de alocacao para generated vs runtime fallback. + +## Persistence Semantics + +Etapa 8 nao foi regredida: Dommel tests passaram, provider SQLite cobre +defaults/computed/read-only/non-identity key, e a documentacao preserva a +semantica historica de `Ignore()`. + +## Advanced Query Materialization + +Etapa 9 nao foi regredida: a suite cobre `QueryMultipleMapped`, `ReadMapped`, +streaming sync/async e cancellation; provider SQLite validou QueryMultiple e +streaming reais. + +## Property Converters + +Etapa 10 nao foi regredida: testes de converter/runtime/generated/TypeHandler +passam dentro da suite; write converters continuam documentados como +metadata-only. + +## Configuration Isolation and DI + +Etapa 11 nao foi regredida: DI tests passaram; runtime isolado segue valido para +caminhos controlados pelo FluentMap; Dapper global type maps e Dommel global +resolvers continuam limitacoes documentadas. + +## Native AOT / Trimming + +| Area | Classification | +| --- | --- | +| Explicit map registration | Trimming-safe preferred path | +| Generated registration | Trimming-safe preferred path for current compilation | +| Generated materializer hot path | AOT-friendlier generated path, subject to query API fallback boundary | +| Assembly scanning | Reflection fallback; trimming-sensitive | +| `QueryMapped*`, `ReadMapped*`, `QueryMultipleMapped`, streaming | Dynamic-code dependent when runtime fallback occurs | +| Full Native AOT | Not validated; not claimed | + +Smokes executados: + +- `explicit:ok`; +- `generated:ok`; +- `di-explicit:ok`; +- `di-generated:ok`; +- `PublishTrimmed=true` DI explicit/generated: publish e execucao passaram com + warnings conhecidos `IL2104`; +- `PublishAot=true` DI explicit: falhou por ausencia de platform linker. + +## Performance + +Benchmark representativo executado com sucesso: + +```bash +dotnet run --project ./benchmarks/Dapper.FluentMap.Benchmarks/Dapper.FluentMap.Benchmarks.csproj --configuration Release -- --filter "*MaterializationSteadyStateBenchmarks*QueryMappedSimple*" --job Dry +``` + +Alocacoes ShortRun relevantes: + +| Scenario | Allocated | +| --- | ---: | +| `QueryMappedSimple` | 261.16 KB | +| `RuntimeQueryMappedSimple` | 261.16 KB | +| `QueryMappedSimpleRuntimeFallback` | 361.58 KB | +| `RuntimeQueryMappedSimpleRuntimeFallback` | 361.58 KB | +| `QueryMappedSimpleUnbuffered` | 245.20 KB | +| `QueryMappedSimpleUnbufferedRuntimeFallback` | 345.49 KB | +| `QueryMappedSimpleUnbufferedAsync` | 245.61 KB | + +Nao foi observada regressao severa frente as baselines SDD. Tempos locais +seguem ruidosos e nao devem virar claim publico de throughput. + +## Security / Supply Chain + +- Vulnerability audit: sem vulnerabilidades reportadas. +- `NuGet.Config`: fonte unica NuGet.org. +- Dependabot configurado para GitHub Actions e NuGet. +- NuGet Audit transitive habilitado. +- CI usa permissoes minimas e actions pinadas por SHA. +- Release workflow gera metadata/provenance e bloqueia publish. +- Scan textual nao encontrou segredo real. +- SBOM formal e package signing permanecem adiados. + +## Documentation + +README, MIGRATION, COMPATIBILITY e SUPPORT correspondem a API real e documentam +os limites de providers, Dommel, AOT/trimming, write converters e global state. +Snippets representativos foram validados no Prompt 12.6 e a suite atual recompila +e testa as APIs correspondentes. + +## Historical Regression Coverage + +Etapas 7-11 continuam cobertas por generated registration/materialization tests, +historical core regressions, historical Dommel regressions, +QueryMultiple/streaming/cancellation tests, converter/TypeHandler tests, +configuration isolation/DI tests e provider SQLite tests. + +## Breaking Changes + +Nenhuma breaking change nova foi introduzida nesta auditoria final. + +Known breaking/risky differences da linha atual: + +- `DommelPropertyMap.GeneratedOption` diverge do pacote Dommel historico + `2.0.0` por tipo nullable; +- validacoes novas podem rejeitar configuracoes contraditorias antes aceitas por + acidente; +- pacote default `2.0.0` nao deve ser publicado pelo fork. + +## Known Limitations + +- Estado global permanece para `FluentMapper`, `SqlMapper.SetTypeMap` e + `DommelMapper`. +- Dommel nao e isolado por `FluentMapRuntime`. +- SQL Server/PostgreSQL nao foram certificados nesta auditoria. +- MySQL/MariaDB nao foi validado. +- `QueryMultipleMappedAsync` nao existe. +- Write converters sao metadata-only no caminho Dapper/Dommel atual. +- Generated materializers possuem fallback runtime. +- Full Native AOT nao e suportado/declarado. + +## Technical Debt + +- Criar baseline API/binaria do fork apos primeira RC. +- Revisar manifests shipped/unshipped dos analyzers/generators antes de stable. +- Validar SourceLink por checksum em CI apos push. +- Decidir package signing e SBOM formal. +- Avaliar package lock ou Central Package Management em tarefa propria. +- Criar job provider real para SQL Server/PostgreSQL se esses providers forem + promovidos a certificados. + +## Release Blockers + +Para RC: + +- nao publicar artefatos `2.0.0`; usar versao pre-release do fork, recomendada + `3.0.0-rc.1`; +- executar o release workflow no GitHub e validar SourceLink/provenance no SHA + remoto; +- instalar os pacotes RC em um consumer smoke antes de qualquer promocao. + +Para stable: + +- baseline API/binaria do proprio fork; +- ciclo de feedback/adocao da RC; +- decisao de SBOM/package signing; +- manifests analyzer/generator fechados; +- SourceLink checksum validado; +- provider certification adicional, se houver claim alem de SQLite; +- Native AOT publish/run se houver claim AOT. + +## Deferred Work + +- Stable release. +- Publicacao NuGet. +- Git tag/GitHub Release. +- Full Native AOT. +- SQL Server/PostgreSQL CI service containers. +- MySQL/MariaDB harness. +- Runtime-isolated Dommel. +- Write converter execution. +- QueryMultiple async. +- SBOM SPDX/CycloneDX. + +## Post-release Recommendations + +1. Publicar primeiro `3.0.0-rc.1`, nao stable. +2. Validar instalacao dos cinco pacotes em um consumer smoke externo. +3. Rodar release workflow com provenance e artifact metadata no GitHub. +4. Criar baseline API/binaria a partir da RC aprovada. +5. Promover para stable somente apos feedback real e blockers zerados ou + explicitamente aceitos. diff --git a/.sdd/etapa-12/STATUS.md b/.sdd/etapa-12/STATUS.md index 713255e..5d1f59b 100644 --- a/.sdd/etapa-12/STATUS.md +++ b/.sdd/etapa-12/STATUS.md @@ -8,11 +8,14 @@ features. ## Estado geral -Etapa 12 iniciou com auditoria documental e baseline de build/test/pack. A -solution esta buildable e testable no ambiente local. A automacao de CI/release -foi preparada, mas a release stable ainda permanece bloqueada por baseline de -API, estrategia final de versionamento, SBOM formal, package signing opcional e -publish NuGet ainda desabilitado. +Status: Concluida com release blockers para stable. + +Ultimo prompt executado: 12.7 + +Etapa 12 foi encerrada com auditoria final de release readiness. A solution esta +buildable, testable, packable e documentada no ambiente local. A recomendacao +final e preparar uma Release Candidate do fork, preferencialmente +`3.0.0-rc.1`, e nao publicar stable neste estado. ## Concluido @@ -102,6 +105,12 @@ publish NuGet ainda desabilitado. auditar, compilar, testar, empacotar, validar artefatos, gerar metadata e gerar provenance. - Mantida publicacao NuGet desabilitada por design. +- Criado `.sdd/etapa-12/FINAL-REPORT.md`. +- Criado `.sdd/ROADMAP-SUMMARY.md`. +- Executada auditoria final do Prompt 12.7. +- Reexecutados restore, build Release, test Release, provider tests, + vulnerability audit, pack padrao, pack RC override, smokes AOT/trimming e + benchmark smoke. ## Em andamento @@ -109,14 +118,24 @@ publish NuGet ainda desabilitado. ## Proximos passos -1. Definir baseline de API do proprio fork apos primeiro RC/versao aprovada. -2. Documentar migration guide, support policy e provider certification. -3. Validar SourceLink URL/checksum em CI apos push. -4. Definir e validar release candidate antes de stable. -5. Fazer auditoria final de release blockers. +1. Executar release workflow no GitHub para `3.0.0-rc.1`, sem publish NuGet. +2. Validar SourceLink URL/checksum e provenance no SHA remoto. +3. Instalar os pacotes RC em consumer smoke externo. +4. Criar baseline API/binaria do proprio fork apos a RC aprovada. +5. Promover para stable somente depois de feedback real e blockers zerados ou + explicitamente aceitos. ## Release blockers +Para RC: + +- Critical se publicar artefato errado: o pack padrao ainda gera `2.0.0`; RC + deve usar override/versionamento `3.0.0-rc.1` ou equivalente. +- High: SourceLink URL/checksum precisa ser validado em CI apos push do commit. +- High: consumer smoke externo dos pacotes RC ainda nao foi executado. + +Para stable: + - Critical: `2.0.0` ja existe no NuGet.org para core e Dommel; a estrategia de versionamento do fork precisa mudar antes de publicar. - Critical: baseline de API do proprio fork ainda precisa ser estabelecida apos @@ -431,18 +450,60 @@ Resultados: - `git diff --check`: sem erros; apenas avisos esperados de normalizacao LF -> CRLF no Windows para `README.md` e `.sdd/etapa-12/STATUS.md`. -## Blockers restantes para 12.7 - -- Critical: estrategia final de versionamento do fork ainda precisa ser - confirmada antes de publicar, pois `2.0.0` ja existe para core/Dommel. -- Critical: baseline de API/binario do proprio fork ainda precisa ser - estabelecida antes de stable. -- High: SourceLink URL/checksum precisa ser validado em CI apos push. -- High: CI ainda nao certifica SQL Server/PostgreSQL com servicos reais nem - smokes trimming/AOT. -- High: interoperabilidade com Dapper TypeHandler depende de boundary interna - por reflection. -- Medium: manifests de release dos analyzers/generators precisam revisao antes - de stable. -- Medium: SBOM formal e package signing seguem decisao futura/adiada. -- Medium: package lock ou Central Package Management ainda nao foram decididos. +## Validacao do Prompt 12.7 + +Executada localmente em 2026-07-29: + +```bash +dotnet --version +dotnet restore ./Dapper.FluentMap.sln +dotnet build ./Dapper.FluentMap.sln --configuration Release --no-restore +dotnet test ./Dapper.FluentMap.sln --configuration Release --no-build +dotnet test ./test/Dapper.FluentMap.ProviderCompatibility.Tests/Dapper.FluentMap.ProviderCompatibility.Tests.csproj --configuration Release --no-build +dotnet list ./Dapper.FluentMap.sln package --vulnerable --include-transitive +dotnet pack ./Dapper.FluentMap.sln --configuration Release --no-build --output ./artifacts/packages-12.7-final +dotnet pack ./Dapper.FluentMap.sln --configuration Release --no-build --output ./artifacts/packages-12.7-rc -p:VersionPrefix=3.0.0-rc.1 +dotnet nuget verify artifacts\packages-12.7-final\*.nupkg +dotnet run --project ./test/Dapper.FluentMap.AotSmoke/Dapper.FluentMap.AotSmoke.csproj --configuration Release -p:DefineConstants=AOT_SMOKE_EXPLICIT -p:UseSharedCompilation=false +dotnet run --project ./test/Dapper.FluentMap.AotSmoke/Dapper.FluentMap.AotSmoke.csproj --configuration Release -p:DefineConstants=AOT_SMOKE_GENERATED -p:UseSharedCompilation=false +dotnet run --project ./test/Dapper.FluentMap.AotSmoke/Dapper.FluentMap.AotSmoke.csproj --configuration Release -p:DefineConstants=AOT_SMOKE_DI_EXPLICIT -p:UseSharedCompilation=false +dotnet run --project ./test/Dapper.FluentMap.AotSmoke/Dapper.FluentMap.AotSmoke.csproj --configuration Release -p:DefineConstants=AOT_SMOKE_DI_GENERATED -p:UseSharedCompilation=false +dotnet publish ./test/Dapper.FluentMap.AotSmoke/Dapper.FluentMap.AotSmoke.csproj --configuration Release -p:PublishTrimmed=true -p:DefineConstants=AOT_SMOKE_DI_EXPLICIT -p:UseSharedCompilation=false --output ./.tmp/aot-smoke-12.7/di-explicit-trimmed +./.tmp/aot-smoke-12.7/di-explicit-trimmed/Dapper.FluentMap.AotSmoke.exe +dotnet publish ./test/Dapper.FluentMap.AotSmoke/Dapper.FluentMap.AotSmoke.csproj --configuration Release -p:PublishTrimmed=true -p:DefineConstants=AOT_SMOKE_DI_GENERATED -p:UseSharedCompilation=false --output ./.tmp/aot-smoke-12.7/di-generated-trimmed +./.tmp/aot-smoke-12.7/di-generated-trimmed/Dapper.FluentMap.AotSmoke.exe +dotnet publish ./test/Dapper.FluentMap.AotSmoke/Dapper.FluentMap.AotSmoke.csproj --configuration Release -p:PublishAot=true -p:DefineConstants=AOT_SMOKE_DI_EXPLICIT -p:UseSharedCompilation=false --output ./.tmp/aot-smoke-12.7/di-explicit-aot +dotnet run --project ./benchmarks/Dapper.FluentMap.Benchmarks/Dapper.FluentMap.Benchmarks.csproj --configuration Release -- --filter "*MaterializationSteadyStateBenchmarks*QueryMappedSimple*" --job Dry +``` + +Resultados: + +- SDK: `10.0.302`. +- Restore: sucesso. +- Build Release: sucesso, 0 warnings, 0 errors. +- Test solution: sucesso; 460 passed, 14 skipped, 0 failed. +- Provider compatibility: SQLite 7 passed; SQL Server/PostgreSQL 14 skipped por + ausencia de connection strings. +- NuGet vulnerability audit: nenhuma vulnerabilidade reportada. +- Pack padrao: sucesso; 5 `.nupkg`, 3 `.snupkg`, versao `2.0.0` nao publicavel. +- Pack RC override `3.0.0-rc.1`: sucesso; 5 `.nupkg`, 3 `.snupkg`. +- Package contents/nuspecs: README, license MIT, repository URL/commit, + dependency ranges e layouts esperados confirmados. +- SourceLink: PDB contem URL GitHub para o commit local; checksum remoto nao + validado porque `sourcelink` nao esta instalado e o SHA precisa existir no + remoto. +- `dotnet nuget verify`: hashes confirmados, falha `NU3004` por pacotes nao + assinados. +- AOT smoke executavel: `explicit:ok`, `generated:ok`, `di-explicit:ok`, + `di-generated:ok`. +- Publish trimmed DI explicit/generated: sucesso e execucao OK; warnings + conhecidos `IL2104`. +- Native AOT publish: falha por ausencia de platform linker/toolchain nativa. +- Benchmark smoke: sucesso; 20 cenarios executados; sem regressao severa de + alocacao observada; tempos sao guardrail, nao claim publico. + +## Recomendacao final + +Release recommendation: Release Candidate. + +Stable: nao recomendado. From a71d0213976c51437e1301bbaae699e4b4519c1d Mon Sep 17 00:00:00 2001 From: Rodrigo de Oliveira Date: Wed, 29 Jul 2026 14:17:41 -0300 Subject: [PATCH 43/49] chore(release): prepare versioning for 3.0.0-rc.1 --- .github/workflows/ci.yml | 5 + .github/workflows/release.yml | 12 +- .sdd/etapa-12/STATUS.md | 2 +- .sdd/release-3.0.0-rc.1/01-release-plan.md | 87 +++++++++++ .sdd/release-3.0.0-rc.1/DECISIONS.md | 165 +++++++++++++++++++++ .sdd/release-3.0.0-rc.1/STATUS.md | 72 +++++++++ CHANGELOG.md | 23 +++ Directory.Build.props | 4 +- Directory.Build.targets | 8 + 9 files changed, 374 insertions(+), 4 deletions(-) create mode 100644 .sdd/release-3.0.0-rc.1/01-release-plan.md create mode 100644 .sdd/release-3.0.0-rc.1/DECISIONS.md create mode 100644 .sdd/release-3.0.0-rc.1/STATUS.md create mode 100644 Directory.Build.targets diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5342d46..bbd9dd3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -158,6 +158,11 @@ jobs: throw "Unexpected test/benchmark artifacts: $($unexpected.Name -join ', ')" } + $unsafe = @($nupkgs + $snupkgs | Where-Object { $_.Name -match '\.(2\.0\.0|3\.0\.0)\.(nupkg|snupkg)$' }) + if ($unsafe.Count -gt 0) { + throw "Unsafe package versions produced by default CI pack: $($unsafe.Name -join ', ')" + } + - name: Write release metadata shell: pwsh run: | diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d427da2..a320ee3 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -58,6 +58,14 @@ jobs: throw "Version 2.0.0 already exists for historical package IDs and must not be used by this fork." } + if ($version -eq '3.0.0') { + throw "Stable version 3.0.0 is disabled during the 3.0.0-rc.1 release freeze." + } + + if ($version -notmatch '-') { + throw "Release freeze requires an explicit prerelease package version." + } + - name: Guard disabled publish path if: ${{ inputs.publish }} shell: pwsh @@ -75,13 +83,13 @@ jobs: run: dotnet list ./Dapper.FluentMap.sln package --vulnerable --include-transitive - name: Build Release - run: dotnet build ./Dapper.FluentMap.sln --configuration Release --no-restore -p:VersionPrefix=${{ inputs.package-version }} + run: dotnet build ./Dapper.FluentMap.sln --configuration Release --no-restore -p:Version=${{ inputs.package-version }} - name: Test Release run: dotnet test ./Dapper.FluentMap.sln --configuration Release --no-build - name: Pack Release - run: dotnet pack ./Dapper.FluentMap.sln --configuration Release --no-build --output ./artifacts/packages -p:VersionPrefix=${{ inputs.package-version }} + run: dotnet pack ./Dapper.FluentMap.sln --configuration Release --no-build --output ./artifacts/packages -p:Version=${{ inputs.package-version }} - name: Validate package artifact set shell: pwsh diff --git a/.sdd/etapa-12/STATUS.md b/.sdd/etapa-12/STATUS.md index 5d1f59b..ed038dc 100644 --- a/.sdd/etapa-12/STATUS.md +++ b/.sdd/etapa-12/STATUS.md @@ -276,7 +276,7 @@ Resultados: ## Ultimo prompt executado -Ultimo prompt executado: 12.6 +Ultimo prompt executado: 12.7 ## Validacao do Prompt 12.3 diff --git a/.sdd/release-3.0.0-rc.1/01-release-plan.md b/.sdd/release-3.0.0-rc.1/01-release-plan.md new file mode 100644 index 0000000..a9e4848 --- /dev/null +++ b/.sdd/release-3.0.0-rc.1/01-release-plan.md @@ -0,0 +1,87 @@ +# Release Plan - 3.0.0-rc.1 + +## Objetivo + +Congelar o escopo da primeira Release Candidate da linha do fork e garantir que +nenhum pack normal produza a versao historica `2.0.0` ou a stable acidental +`3.0.0`. + +## Escopo congelado + +- Generated materialization e runtime fallback. +- Persistence semantics para metadata de escrita e integracao Dommel. +- Advanced query materialization com QueryMultiple, ReadMapped e streaming. +- Property converters para leitura controlada pelo FluentMap. +- Isolated configuration, `FluentMapRuntime` e DI. +- Compatibility, provider matrix, package metadata, CI/release hardening e + documentacao publica das Etapas 7-12. + +## Fora de escopo + +- Publicacao NuGet. +- Criacao de tag ou GitHub Release. +- Stable `3.0.0`. +- Correcoes funcionais nao relacionadas ao release freeze. +- Baseline API/binaria definitiva para stable. +- SBOM formal, package signing, Native AOT completo e certificacao real de SQL + Server/PostgreSQL. + +## Packages + +- `Dapper.FluentMap` +- `Dapper.FluentMap.Dommel` +- `Dapper.FluentMap.DependencyInjection` +- `Dapper.FluentMap.Analyzers` +- `Dapper.FluentMap.Generators` + +## Versionamento + +- Versao base da linha: `3.0.0`. +- Versao local padrao: `3.0.0-dev`. +- Versao da RC: `3.0.0-rc.1`. +- `Directory.Build.props` centraliza `FluentMapPackageVersionPrefix=3.0.0` e + `VersionSuffix=dev` quando nenhuma versao explicita e informada. +- `Directory.Build.targets` bloqueia pack de `2.0.0` e `3.0.0` durante o freeze. +- Release workflow deve usar `-p:Version=` e rejeitar stable + durante esta RC. + +## Branch strategy + +- Branch de origem: `feature/etapa-3`. +- Branch de release local: `release/3.0.0-rc.1`. +- Commit base: `15e926c` (`chore(release): complete FluentMap readiness audit`). +- A branch antiga foi mantida intacta; nenhuma alteracao foi feita diretamente + em `master`. + +## Commit strategy + +- Um unico commit semantico para o prompt RC.1: + `chore(release): prepare versioning for 3.0.0-rc.1`. +- Nao fazer push neste prompt. +- Nao misturar bug fixes ou features com o freeze. + +## Release gates + +- `dotnet restore ./Dapper.FluentMap.sln` +- `dotnet build ./Dapper.FluentMap.sln --configuration Release --no-restore` +- `dotnet test ./Dapper.FluentMap.sln --configuration Release --no-build` +- Pack padrao deve gerar somente artefatos `3.0.0-dev`. +- Pack explicito da RC deve gerar exatamente artefatos `3.0.0-rc.1`. +- A pasta RC deve conter 5 `.nupkg` e 3 `.snupkg`. +- Nenhum artefato `2.0.0` ou stable `3.0.0` pode ser produzido. + +## Rollback strategy + +- Como nao ha push, tag ou publicacao, rollback local e remover/reverter o commit + RC.1 antes de publicar a branch. +- Se o workflow remoto falhar apos push futuro, manter os artefatos sem publicar + e corrigir em novo commit na mesma branch de release. + +## Riscos conhecidos + +- SourceLink/provenance dependem de execucao no SHA remoto apos push. +- SQL Server/PostgreSQL seguem com harness condicional, nao certificados. +- Native AOT completo nao foi validado. +- Package signing e SBOM formal permanecem decisoes futuras. +- A pasta nao rastreada `src/Dapper.FluentMap/etapas/` ja existia antes deste + prompt e nao faz parte da RC. diff --git a/.sdd/release-3.0.0-rc.1/DECISIONS.md b/.sdd/release-3.0.0-rc.1/DECISIONS.md new file mode 100644 index 0000000..2cfcc50 --- /dev/null +++ b/.sdd/release-3.0.0-rc.1/DECISIONS.md @@ -0,0 +1,165 @@ +# Release 3.0.0-rc.1 Decisions + +## ADR-RC-1 - Versao 3.0.0-rc.1 + +### Contexto + +As Etapas 7-12 adicionaram uma superficie publica relevante e a auditoria final +recomendou Release Candidate, nao stable. Os package IDs historicos de core e +Dommel ja possuem `2.0.0` publicado. + +### Decisao + +Usar `3.0.0-rc.1` como versao pretendida da primeira RC do fork. + +### Alternativas consideradas + +- `2.0.0`: rejeitada porque ja existe nos IDs historicos. +- `2.1.0-rc.1`: rejeitada para esta RC porque a mudanca acumulada e grande e + ainda nao ha baseline API/binaria do fork. +- `3.0.0` stable: rejeitada ate feedback de RC e gates stable. + +### Consequencias + +A linha assume SemVer major nova para reduzir ambiguidade com o pacote original +e permitir validacao publica antes de stable. + +## ADR-RC-2 - Versao padrao segura de desenvolvimento + +### Contexto + +Antes deste freeze, `dotnet pack` padrao produzia `2.0.0`. + +### Decisao + +O pack local padrao passa a produzir `3.0.0-dev`. Versoes de release precisam +ser informadas explicitamente por `Version`. + +### Alternativas consideradas + +- Deixar `2.0.0` como padrao e depender de disciplina: rejeitada por risco de + artefato errado. +- Usar `3.0.0` como padrao: rejeitada por risco de stable acidental. + +### Consequencias + +Builds locais continuam simples, mas seus pacotes sao claramente prerelease de +desenvolvimento. + +## ADR-RC-3 - Branch de release + +### Contexto + +O checkout estava em `feature/etapa-3`, nome inadequado para release freeze. + +### Decisao + +Criar a branch local `release/3.0.0-rc.1` a partir de `15e926c`. + +### Alternativas consideradas + +- Continuar em `feature/etapa-3`: rejeitada por higiene de release. +- Criar a partir de `master`: rejeitada porque o trabalho das Etapas 7-12 esta + na branch atual. + +### Consequencias + +O historico de release fica separado sem merge, rebase destrutivo ou force push. + +## ADR-RC-4 - Proibicao de publicacao de 2.0.0 + +### Contexto + +`Dapper.FluentMap` e `Dapper.FluentMap.Dommel` ja possuem `2.0.0` historico no +NuGet.org. + +### Decisao + +Bloquear pack de `2.0.0` via MSBuild e rejeitar `2.0.0` no workflow de release. + +### Alternativas consideradas + +- Documentar apenas a proibicao: rejeitada porque nao impede erro operacional. + +### Consequencias + +Um comando normal de pack ou release nao pode produzir o numero historico por +acidente. + +## ADR-RC-5 - Politica de correcoes durante o freeze + +### Contexto + +O objetivo da RC.1 e congelar escopo, nao continuar feature work. + +### Decisao + +Aceitar somente correcoes diretamente ligadas a release blockers, validacao, +versionamento, packaging ou documentacao critica de release. + +### Alternativas consideradas + +- Corrigir bugs oportunistas durante o freeze: rejeitada por aumentar risco. + +### Consequencias + +Correcoes funcionais novas devem esperar nova etapa ou novo prompt explicito. + +## ADR-RC-6 - Politica de push + +### Contexto + +Este prompt proibe push, tag, GitHub Release e publicacao. + +### Decisao + +Nao fazer push neste prompt. Push futuro deve ocorrer somente apos revisao local +do diff, commit validado e decisao explicita. + +### Alternativas consideradas + +- Push imediato da branch de release: rejeitada pelas restricoes do prompt. + +### Consequencias + +Todos os artefatos deste prompt permanecem locais. + +## ADR-RC-7 - Tag pretendida + +### Contexto + +A RC precisa de uma tag futura previsivel, mas este prompt proibe cria-la. + +### Decisao + +A tag pretendida, quando autorizada, e `v3.0.0-rc.1`. + +### Alternativas consideradas + +- `3.0.0-rc.1` sem prefixo `v`: rejeitada para manter compatibilidade com tags + historicas do repositorio. + +### Consequencias + +Nenhuma tag e criada agora; o nome fica reservado no plano. + +## ADR-RC-8 - Packages participantes + +### Contexto + +A solution possui cinco projetos packable publicos. + +### Decisao + +Os cinco packages participam da RC e devem compartilhar exatamente a mesma +versao. + +### Alternativas consideradas + +- Publicar apenas core/Dommel: rejeitada porque DI, analyzers e generators fazem + parte da linha validada. + +### Consequencias + +Pack default e pack RC devem gerar cinco `.nupkg`; core, Dommel e DI tambem +devem gerar `.snupkg`. diff --git a/.sdd/release-3.0.0-rc.1/STATUS.md b/.sdd/release-3.0.0-rc.1/STATUS.md new file mode 100644 index 0000000..a7f523a --- /dev/null +++ b/.sdd/release-3.0.0-rc.1/STATUS.md @@ -0,0 +1,72 @@ +# Release 3.0.0-rc.1 Status + +## Estado + +RC.1 freeze preparado localmente. Escopo congelado, versionamento seguro e +validacao local concluidos neste prompt. + +## Commit candidato atual + +Base inicial: `15e926c` (`chore(release): complete FluentMap readiness audit`). +O commit RC.1 local passa a ser o candidato apos o commit semantico deste +prompt. + +## Branch + +- Origem: `feature/etapa-3`. +- Atual/final esperada: `release/3.0.0-rc.1`. +- Nenhum push executado neste prompt. + +## Concluido + +- Discovery inicial de Git, historico, SDD, documentacao publica, projetos, + workflows e versionamento. +- Pasta `.sdd/release-3.0.0-rc.1/` criada. +- Plano de release e ADRs da RC criados. +- Versionamento central seguro definido para `3.0.0-dev` local. +- Pack de `2.0.0` e stable `3.0.0` bloqueado durante o freeze. +- Workflow de release ajustado para usar `Version` explicito e prerelease + validada. +- Changelog preparado com `3.0.0-rc.1 - Unreleased`. +- Restore, build, test, pack default, pack RC e bloqueios negativos de versao + validados localmente. + +## Em andamento + +- Nenhuma tarefa em andamento. + +## Proximos passos + +1. Revisar `git diff` e `git diff --check`. +2. Criar o commit `chore(release): prepare versioning for 3.0.0-rc.1`. +3. Em prompt futuro, executar workflow remoto apos push autorizado. + +## RC blockers + +- SourceLink/provenance ainda precisam ser validados em SHA remoto apos push. +- Consumer smoke externo com os cinco pacotes RC ainda nao foi executado. + +## Stable-only blockers + +- Baseline API/binaria do fork. +- Feedback de adocao da RC. +- Decisao de package signing e SBOM formal. +- Revisao final de analyzer/generator release manifests. +- Certificacao adicional de providers se houver claim alem de SQLite. +- Native AOT publish/run se houver claim AOT. + +## Artifacts + +- Default: `artifacts/release-3.0.0-rc.1/default`, com 5 `.nupkg` e 3 + `.snupkg` em `3.0.0-dev`. +- RC: `artifacts/release-3.0.0-rc.1/rc`, com 5 `.nupkg` e 3 `.snupkg` em + `3.0.0-rc.1`. +- Bloqueios negativos confirmados para `2.0.0` e stable `3.0.0`. + +## Workflow runs + +- Nenhum workflow remoto executado neste prompt. + +## Ultimo prompt executado + +Ultimo prompt executado: RC.1 diff --git a/CHANGELOG.md b/CHANGELOG.md index a681b07..87c1cb5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,29 @@ The historical archived package history is not reconstructed here. This changelo - README is now a concise bilingual entrypoint and delegates detailed release/adoption policy to dedicated documents. +## [3.0.0-rc.1] - Unreleased + +### Added + +- Generated materialization support for eligible explicit mappings, including runtime fallback for unsupported shapes. +- Persistence semantics for read-only, computed, insert-excluded, update-excluded and database-default columns. +- Advanced FluentMap-controlled query materialization, including `QueryMultipleMapped`, `ReadMapped*` and sync/async streaming helpers. +- Property converter metadata and read conversion in FluentMap-controlled materialization. +- Isolated immutable configuration, `FluentMapRuntime` and optional dependency injection integration. + +### Changed + +- Release versioning is hardened so default local pack produces `3.0.0-dev`, not historical `2.0.0` or accidental stable `3.0.0`. +- Release workflow uses an explicit validated package version for RC artifacts. + +### Known Limitations + +- Normal `Dapper.Query()`, `SqlMapper.SetTypeMap` and Dommel integration remain process-wide. +- Write converters are metadata-only in the current Dapper/Dommel write path. +- SQL Server and PostgreSQL have conditional harnesses but are not certified in CI. +- Full Native AOT support is not claimed. +- Stable release remains blocked on fork-owned API/binary baseline, SourceLink validation on remote SHA, package signing/SBOM decision and RC feedback. + ## Fork Release Candidate Line The first fork release candidate is expected to use a prerelease version such as `3.0.0-rc.1`, unless API compatibility review proves a different versioning path is safer. diff --git a/Directory.Build.props b/Directory.Build.props index 7357a7b..c10e73e 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -1,6 +1,8 @@ - 2.0.0 + 3.0.0 + dev + $(FluentMapPackageVersionSuffix) 2.1.79 2.1.79 [$(DapperMinimumSupportedVersion),3.0.0) diff --git a/Directory.Build.targets b/Directory.Build.targets new file mode 100644 index 0000000..5c08455 --- /dev/null +++ b/Directory.Build.targets @@ -0,0 +1,8 @@ + + + + + + From e6e462782c0151763679fc7802518b8026333d54 Mon Sep 17 00:00:00 2001 From: Rodrigo de Oliveira Date: Wed, 29 Jul 2026 14:33:20 -0300 Subject: [PATCH 44/49] ci(release): qualify 3.0.0-rc.1 artifacts --- .github/workflows/release.yml | 82 +--- .../02-local-qualification.md | 139 ++++++ .sdd/release-3.0.0-rc.1/DECISIONS.md | 29 ++ .sdd/release-3.0.0-rc.1/STATUS.md | 32 +- eng/validate-release-artifacts.ps1 | 449 ++++++++++++++++++ 5 files changed, 660 insertions(+), 71 deletions(-) create mode 100644 .sdd/release-3.0.0-rc.1/02-local-qualification.md create mode 100644 eng/validate-release-artifacts.ps1 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index a320ee3..3a3f417 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -50,20 +50,17 @@ jobs: $ErrorActionPreference = 'Stop' $version = '${{ inputs.package-version }}' - if ($version -notmatch '^\d+\.\d+\.\d+(-[0-9A-Za-z][0-9A-Za-z.-]*)?$') { + if ($version -notmatch '^\d+\.\d+\.\d+(-[0-9A-Za-z][0-9A-Za-z.-]*)$') { throw "Package version '$version' is not a supported SemVer value." } - if ($version -eq '2.0.0') { - throw "Version 2.0.0 already exists for historical package IDs and must not be used by this fork." + if ($version -ne '3.0.0-rc.1') { + throw "This release workflow is locked to 3.0.0-rc.1. Received '$version'." } - if ($version -eq '3.0.0') { - throw "Stable version 3.0.0 is disabled during the 3.0.0-rc.1 release freeze." - } - - if ($version -notmatch '-') { - throw "Release freeze requires an explicit prerelease package version." + $ref = '${{ github.ref }}' + if ($ref -ne 'refs/heads/release/3.0.0-rc.1') { + throw "This release workflow is locked to refs/heads/release/3.0.0-rc.1. Received '$ref'." } - name: Guard disabled publish path @@ -91,38 +88,20 @@ jobs: - name: Pack Release run: dotnet pack ./Dapper.FluentMap.sln --configuration Release --no-build --output ./artifacts/packages -p:Version=${{ inputs.package-version }} - - name: Validate package artifact set + - name: Validate release artifacts and write manifest shell: pwsh run: | $ErrorActionPreference = 'Stop' - $packageDir = './artifacts/packages' - $version = '${{ inputs.package-version }}' - $expectedNupkgs = @( - "Dapper.FluentMap.$version.nupkg", - "Dapper.FluentMap.Dommel.$version.nupkg", - "Dapper.FluentMap.DependencyInjection.$version.nupkg", - "Dapper.FluentMap.Analyzers.$version.nupkg", - "Dapper.FluentMap.Generators.$version.nupkg" - ) - $expectedSnupkgs = @( - "Dapper.FluentMap.$version.snupkg", - "Dapper.FluentMap.Dommel.$version.snupkg", - "Dapper.FluentMap.DependencyInjection.$version.snupkg" - ) - $actualNupkgs = @(Get-ChildItem -Path $packageDir -Filter '*.nupkg' -File | Select-Object -ExpandProperty Name) - $actualSnupkgs = @(Get-ChildItem -Path $packageDir -Filter '*.snupkg' -File | Select-Object -ExpandProperty Name) - $missing = @($expectedNupkgs + $expectedSnupkgs | Where-Object { $_ -notin @($actualNupkgs + $actualSnupkgs) }) - $unexpected = @($actualNupkgs + $actualSnupkgs | Where-Object { $_ -notin @($expectedNupkgs + $expectedSnupkgs) }) - - if ($missing.Count -gt 0) { - throw "Missing package artifacts: $($missing -join ', ')" - } - - if ($unexpected.Count -gt 0) { - throw "Unexpected package artifacts: $($unexpected -join ', ')" - } - - - name: Write release metadata + ./eng/validate-release-artifacts.ps1 ` + -PackageDirectory './artifacts/packages' ` + -Version '${{ inputs.package-version }}' ` + -ManifestPath './artifacts/release-metadata/artifact-manifest.json' ` + -Repository '${{ github.repository }}' ` + -RepositoryUrl 'https://github.com/${{ github.repository }}' ` + -Commit '${{ github.sha }}' ` + -Branch '${{ github.ref }}' + + - name: Write dependency inventory shell: pwsh run: | $ErrorActionPreference = 'Stop' @@ -130,33 +109,6 @@ jobs: dotnet list ./Dapper.FluentMap.sln package --include-transitive --format json | Set-Content -Encoding utf8NoBOM -Path './artifacts/release-metadata/dependencies.json' - $packageFiles = @( - Get-ChildItem -Path './artifacts/packages' -Filter '*.nupkg' -File - Get-ChildItem -Path './artifacts/packages' -Filter '*.snupkg' -File - ) | - Sort-Object Name | - ForEach-Object { - [ordered]@{ - name = $_.Name - sha256 = (Get-FileHash -LiteralPath $_.FullName -Algorithm SHA256).Hash.ToLowerInvariant() - size = $_.Length - } - } - - [ordered]@{ - schemaVersion = '1.0' - repository = '${{ github.repository }}' - ref = '${{ github.ref }}' - sha = '${{ github.sha }}' - runId = '${{ github.run_id }}' - runAttempt = '${{ github.run_attempt }}' - packageVersion = '${{ inputs.package-version }}' - dotnetSdk = (dotnet --version) - packageFiles = @($packageFiles) - } | - ConvertTo-Json -Depth 5 | - Set-Content -Encoding utf8NoBOM -Path './artifacts/release-metadata/release-metadata.json' - - name: Upload release package artifacts uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: diff --git a/.sdd/release-3.0.0-rc.1/02-local-qualification.md b/.sdd/release-3.0.0-rc.1/02-local-qualification.md new file mode 100644 index 0000000..56be7de --- /dev/null +++ b/.sdd/release-3.0.0-rc.1/02-local-qualification.md @@ -0,0 +1,139 @@ +# Local Qualification + +## Candidate version + +Versao candidata unica: `3.0.0-rc.1`. + +O gate local e o workflow de release devem rejeitar `2.0.0`, `3.0.0`, +versoes sem suffix pre-release e qualquer valor diferente de `3.0.0-rc.1`. + +## Candidate branch + +Branch local de qualificacao: `release/3.0.0-rc.1`. + +O commit remoto a ser qualificado no Prompt RC.3 e o commit resultante do +Prompt RC.2, com mensagem `ci(release): qualify 3.0.0-rc.1 artifacts`. + +## Build commands + +```bash +dotnet restore ./Dapper.FluentMap.sln +dotnet build ./Dapper.FluentMap.sln --configuration Release --no-restore +``` + +## Test commands + +```bash +dotnet test ./Dapper.FluentMap.sln --configuration Release --no-build +dotnet test ./test/Dapper.FluentMap.ProviderCompatibility.Tests/Dapper.FluentMap.ProviderCompatibility.Tests.csproj --configuration Release --no-build +``` + +Os testes de provider executam SQLite localmente. SQL Server e PostgreSQL +permanecem condicionais a connection strings de ambiente. + +## Package commands + +```bash +dotnet pack ./Dapper.FluentMap.sln --configuration Release --no-build --output ./artifacts/release-3.0.0-rc.1/rc2 -p:Version=3.0.0-rc.1 +``` + +## Expected artifacts + +- 5 `.nupkg`: + - `Dapper.FluentMap.3.0.0-rc.1.nupkg` + - `Dapper.FluentMap.Dommel.3.0.0-rc.1.nupkg` + - `Dapper.FluentMap.DependencyInjection.3.0.0-rc.1.nupkg` + - `Dapper.FluentMap.Analyzers.3.0.0-rc.1.nupkg` + - `Dapper.FluentMap.Generators.3.0.0-rc.1.nupkg` +- 3 `.snupkg`: + - `Dapper.FluentMap.3.0.0-rc.1.snupkg` + - `Dapper.FluentMap.Dommel.3.0.0-rc.1.snupkg` + - `Dapper.FluentMap.DependencyInjection.3.0.0-rc.1.snupkg` +- `artifact-manifest.json` +- `dependencies.json` + +## Version validation + +O workflow recebe `package-version` explicitamente e valida que o valor e +exatamente `3.0.0-rc.1`. O pack usa somente `-p:Version=`. + +O script `eng/validate-release-artifacts.ps1` valida novamente a versao nos +nomes dos arquivos e nos nuspecs de todos os `.nupkg` e `.snupkg`. + +## Package validation + +O pack executa a validacao nativa de pacote nos projetos com +`EnablePackageValidation=true`. + +O script de artefatos valida: + +- contagem exata de 5 `.nupkg` e 3 `.snupkg`; +- ausencia de pacotes de tests, benchmarks e AOT smoke; +- PackageIds esperados; +- versao identica em todos os nuspecs; +- repository URL, branch e commit; +- dependency ranges esperados; +- README presente; +- license MIT; +- layout `lib/netstandard2.0` para runtime packages; +- layout `analyzers/dotnet/cs` para analyzers e generators. + +## SourceLink preparation + +O build preserva `RepositoryUrl`, `PublishRepositoryUrl`, +`EmbedUntrackedSources`, `Deterministic` e `ContinuousIntegrationBuild` em CI. + +O script valida o repository commit gravado nos nuspecs. A validacao completa +de SourceLink contra URL remota permanece bloqueada ate o commit RC.2 existir no +remoto, no Prompt RC.3. + +## Provenance preparation + +O workflow mantem attestations em job separado, com permissoes limitadas a: + +- `contents: read`; +- `id-token: write`; +- `attestations: write`. + +O manifest inclui `version`, `repository`, `repositoryUrl`, `commit`, `branch` +e SHA-256 de todos os artefatos. + +## Security gates + +- Publish segue desabilitado. +- Nao existe `dotnet nuget push` ativo no workflow. +- Nenhum secret NuGet e necessario. +- CI nao usa `pull_request_target`. +- PRs de fork nao possuem caminho de publish. +- O input manual `publish` apenas falha com mensagem explicita. +- Dependencias sao auditadas com `dotnet list package --vulnerable + --include-transitive`. + +## Results + +Gate local RC.2 executado em 2026-07-29: + +- `dotnet restore ./Dapper.FluentMap.sln`: passou. +- `dotnet list ./Dapper.FluentMap.sln package --vulnerable + --include-transitive`: passou sem vulnerabilidades reportadas. +- `dotnet build ./Dapper.FluentMap.sln --configuration Release + --no-restore`: passou com 0 warnings e 0 erros. +- `dotnet test ./Dapper.FluentMap.sln --configuration Release --no-build`: + passou. +- `dotnet test ./test/Dapper.FluentMap.ProviderCompatibility.Tests/Dapper.FluentMap.ProviderCompatibility.Tests.csproj + --configuration Release --no-build`: passou com SQLite executado; SQL Server + e PostgreSQL foram ignorados por falta de connection strings. +- `dotnet pack ./Dapper.FluentMap.sln --configuration Release --no-build + --output ./artifacts/release-3.0.0-rc.1/rc2-local + -p:Version=3.0.0-rc.1`: passou e gerou 5 `.nupkg` e 3 `.snupkg`. +- `eng/validate-release-artifacts.ps1`: passou e gerou + `artifact-manifest.json`. +- Checagens negativas do gate rejeitaram `2.0.0`, `3.0.0` e + `3.0.0-beta.1`. +- `.github/workflows/ci.yml`, `.github/workflows/release.yml` e + `.github/dependabot.yml` foram parseados como YAML valido via PyYAML. +- Busca por `dotnet nuget push`, secrets NuGet, `pull_request_target` e + ambiente de publish ativo nao encontrou matches. + +SourceLink remoto e provenance continuam dependentes do push futuro do commit +RC.2, sem publicacao NuGet. diff --git a/.sdd/release-3.0.0-rc.1/DECISIONS.md b/.sdd/release-3.0.0-rc.1/DECISIONS.md index 2cfcc50..80e18fc 100644 --- a/.sdd/release-3.0.0-rc.1/DECISIONS.md +++ b/.sdd/release-3.0.0-rc.1/DECISIONS.md @@ -163,3 +163,32 @@ versao. Pack default e pack RC devem gerar cinco `.nupkg`; core, Dommel e DI tambem devem gerar `.snupkg`. + +## ADR-RC-9 - Manifest e validacao de artefatos + +### Contexto + +O workflow RC.1 validava a contagem basica de artefatos diretamente no YAML e +gerava metadata simples. Para a qualificacao final da RC, o gate precisa +inspecionar nuspecs, dependency ranges, repository commit, layouts de pacote e +checksums sem duplicar regras complexas no workflow. + +### Decisao + +Criar `eng/validate-release-artifacts.ps1` como contrato reutilizavel local e +CI para validar os artefatos da versao `3.0.0-rc.1` e gerar +`artifact-manifest.json`. + +### Alternativas consideradas + +- Manter toda a logica em YAML: rejeitada por baixa reutilizacao local e maior + risco de divergencia. +- Adicionar ferramenta externa de manifest/SBOM neste prompt: rejeitada porque + SBOM formal permanece decisao futura e o gate atual nao deve introduzir nova + dependencia operacional. + +### Consequencias + +O workflow de release passa a orquestrar restore, audit, build, test, pack, +validacao, manifest e provenance. A semantica de validacao dos artefatos fica +concentrada no script sem exigir secrets nem publicar pacotes. diff --git a/.sdd/release-3.0.0-rc.1/STATUS.md b/.sdd/release-3.0.0-rc.1/STATUS.md index a7f523a..cce4008 100644 --- a/.sdd/release-3.0.0-rc.1/STATUS.md +++ b/.sdd/release-3.0.0-rc.1/STATUS.md @@ -2,14 +2,19 @@ ## Estado -RC.1 freeze preparado localmente. Escopo congelado, versionamento seguro e -validacao local concluidos neste prompt. +RC.2 qualificado localmente. O workflow de release agora atua como gate seguro +para gerar exatamente `3.0.0-rc.1`, validar artefatos, gerar manifest com +checksums e preparar provenance sem publicar. ## Commit candidato atual Base inicial: `15e926c` (`chore(release): complete FluentMap readiness audit`). -O commit RC.1 local passa a ser o candidato apos o commit semantico deste -prompt. +RC.1 local: `a71d0213976c51437e1301bbaae699e4b4519c1d` +(`chore(release): prepare versioning for 3.0.0-rc.1`). +O commit que deve ser usado na qualificacao remota do Prompt RC.3 e o commit +final do Prompt RC.2, com mensagem +`ci(release): qualify 3.0.0-rc.1 artifacts`. O hash exato sera obtido apos a +criacao do commit, porque registra-lo dentro do proprio commit alteraria o hash. ## Branch @@ -30,6 +35,18 @@ prompt. - Changelog preparado com `3.0.0-rc.1 - Unreleased`. - Restore, build, test, pack default, pack RC e bloqueios negativos de versao validados localmente. +- Especificacao `.sdd/release-3.0.0-rc.1/02-local-qualification.md` criada. +- Script `eng/validate-release-artifacts.ps1` criado para validacao local/CI dos + artefatos da RC. +- Workflow de release endurecido para aceitar somente `3.0.0-rc.1` na branch + `refs/heads/release/3.0.0-rc.1`. +- Manifest `artifact-manifest.json` gerado com repository, commit, branch, + PackageIds, versoes e SHA-256. +- Validacao de artefatos cobre 5 `.nupkg`, 3 `.snupkg`, ausencia de test, + benchmark e AOT smoke packages, nuspecs, repository commit, dependency ranges, + README, license MIT e layouts analyzer/generator. +- Gate local RC.2 executado: restore, audit, build, test da solution, provider + SQLite, pack, manifest, YAML parse e `git diff --check`. ## Em andamento @@ -43,7 +60,8 @@ prompt. ## RC blockers -- SourceLink/provenance ainda precisam ser validados em SHA remoto apos push. +- SourceLink/provenance ainda precisam ser validados em SHA remoto apos push do + commit RC.2. - Consumer smoke externo com os cinco pacotes RC ainda nao foi executado. ## Stable-only blockers @@ -61,6 +79,8 @@ prompt. `.snupkg` em `3.0.0-dev`. - RC: `artifacts/release-3.0.0-rc.1/rc`, com 5 `.nupkg` e 3 `.snupkg` em `3.0.0-rc.1`. +- RC.2 local: `artifacts/release-3.0.0-rc.1/rc2-local`, com 5 `.nupkg`, 3 + `.snupkg`, `artifact-manifest.json` e `dependencies.json`. - Bloqueios negativos confirmados para `2.0.0` e stable `3.0.0`. ## Workflow runs @@ -69,4 +89,4 @@ prompt. ## Ultimo prompt executado -Ultimo prompt executado: RC.1 +Ultimo prompt executado: RC.2 diff --git a/eng/validate-release-artifacts.ps1 b/eng/validate-release-artifacts.ps1 new file mode 100644 index 0000000..2129650 --- /dev/null +++ b/eng/validate-release-artifacts.ps1 @@ -0,0 +1,449 @@ +[CmdletBinding()] +param( + [Parameter(Mandatory = $true)] + [string]$PackageDirectory, + + [Parameter(Mandatory = $true)] + [string]$Version, + + [string]$ManifestPath, + + [string]$Repository, + + [string]$RepositoryUrl, + + [string]$Commit, + + [string]$Branch +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +$candidateVersion = '3.0.0-rc.1' +$candidateBranch = 'refs/heads/release/3.0.0-rc.1' +$expectedRepositoryUrl = 'https://github.com/rodri-oliveira-dev/Dapper-FluentMap' +$expectedNupkgIds = @( + 'Dapper.FluentMap', + 'Dapper.FluentMap.Dommel', + 'Dapper.FluentMap.DependencyInjection', + 'Dapper.FluentMap.Analyzers', + 'Dapper.FluentMap.Generators' +) +$expectedSnupkgIds = @( + 'Dapper.FluentMap', + 'Dapper.FluentMap.Dommel', + 'Dapper.FluentMap.DependencyInjection' +) +$expectedDependencies = @{ + 'Dapper.FluentMap' = @{ + 'Dapper' = '[2.1.79, 3.0.0)' + 'Microsoft.Bcl.AsyncInterfaces' = '10.0.8' + } + 'Dapper.FluentMap.Dommel' = @{ + 'Dapper.FluentMap' = $Version + 'Dapper' = '[2.1.79, 3.0.0)' + 'Dommel' = '[3.5.3, 4.0.0)' + } + 'Dapper.FluentMap.DependencyInjection' = @{ + 'Dapper.FluentMap' = $Version + 'Microsoft.Extensions.DependencyInjection.Abstractions' = '10.0.10' + } + 'Dapper.FluentMap.Analyzers' = @{} + 'Dapper.FluentMap.Generators' = @{} +} + +function Fail { + param([string]$Message) + throw "Release artifact validation failed: $Message" +} + +function Get-GitOutput { + param([string[]]$Arguments) + + $output = & git @Arguments 2>$null + if ($LASTEXITCODE -ne 0) { + return $null + } + + return ($output | Select-Object -First 1) +} + +function Get-RequiredXmlNode { + param( + [xml]$Document, + [string]$XPath, + [string]$Description + ) + + $node = $Document.SelectSingleNode($XPath) + if ($null -eq $node) { + Fail "Missing $Description." + } + + return $node +} + +function Get-ChildText { + param( + [System.Xml.XmlNode]$Node, + [string]$Name + ) + + $child = $Node.ChildNodes | Where-Object { $_.LocalName -eq $Name } | Select-Object -First 1 + if ($null -eq $child) { + return $null + } + + return $child.InnerText +} + +function Read-Nuspec { + param([System.IO.Compression.ZipArchive]$Archive) + + $nuspecEntries = @($Archive.Entries | Where-Object { $_.FullName -like '*.nuspec' }) + if ($nuspecEntries.Count -ne 1) { + Fail "Expected exactly one nuspec in package archive, found $($nuspecEntries.Count)." + } + + $stream = $nuspecEntries[0].Open() + try { + $reader = [System.IO.StreamReader]::new($stream) + try { + return [xml]$reader.ReadToEnd() + } + finally { + $reader.Dispose() + } + } + finally { + $stream.Dispose() + } +} + +function Get-ZipPackageInfo { + param([System.IO.FileInfo]$File) + + $archive = [System.IO.Compression.ZipFile]::OpenRead($File.FullName) + try { + $entries = @($archive.Entries | ForEach-Object { $_.FullName }) + $nuspec = Read-Nuspec -Archive $archive + $metadata = Get-RequiredXmlNode ` + -Document $nuspec ` + -XPath '//*[local-name()="metadata"]' ` + -Description "nuspec metadata in $($File.Name)" + + $dependencies = @{} + foreach ($dependency in @($nuspec.SelectNodes('//*[local-name()="dependency"]'))) { + $dependencies[$dependency.GetAttribute('id')] = $dependency.GetAttribute('version') + } + + $repositoryNode = $metadata.ChildNodes | + Where-Object { $_.LocalName -eq 'repository' } | + Select-Object -First 1 + $licenseNode = $metadata.ChildNodes | + Where-Object { $_.LocalName -eq 'license' } | + Select-Object -First 1 + + return [pscustomobject]@{ + File = $File + Entries = $entries + Id = Get-ChildText -Node $metadata -Name 'id' + Version = Get-ChildText -Node $metadata -Name 'version' + LicenseType = if ($null -eq $licenseNode) { $null } else { $licenseNode.GetAttribute('type') } + License = Get-ChildText -Node $metadata -Name 'license' + Readme = Get-ChildText -Node $metadata -Name 'readme' + ProjectUrl = Get-ChildText -Node $metadata -Name 'projectUrl' + RepositoryUrl = if ($null -eq $repositoryNode) { $null } else { $repositoryNode.GetAttribute('url') } + RepositoryCommit = if ($null -eq $repositoryNode) { $null } else { $repositoryNode.GetAttribute('commit') } + RepositoryBranch = if ($null -eq $repositoryNode) { $null } else { $repositoryNode.GetAttribute('branch') } + Dependencies = $dependencies + } + } + finally { + $archive.Dispose() + } +} + +function Assert-SetEquals { + param( + [string[]]$Expected, + [string[]]$Actual, + [string]$Description + ) + + $missing = @($Expected | Where-Object { $_ -notin $Actual }) + $unexpected = @($Actual | Where-Object { $_ -notin $Expected }) + if ($missing.Count -gt 0 -or $unexpected.Count -gt 0) { + Fail "$Description mismatch. Missing: $($missing -join ', '); unexpected: $($unexpected -join ', ')." + } +} + +function Assert-Dependencies { + param( + [string]$PackageId, + [hashtable]$Actual, + [hashtable]$Expected + ) + + Assert-SetEquals ` + -Expected ([string[]]$Expected.Keys) ` + -Actual ([string[]]$Actual.Keys) ` + -Description "Dependency IDs for $PackageId" + + foreach ($dependencyId in $Expected.Keys) { + if ($Actual[$dependencyId] -ne $Expected[$dependencyId]) { + Fail "Dependency $dependencyId in $PackageId has version '$($Actual[$dependencyId])', expected '$($Expected[$dependencyId])'." + } + } +} + +function Assert-CommonPackageMetadata { + param( + [pscustomobject]$PackageInfo, + [string]$ExpectedId, + [bool]$RequireReadmeAndLicense = $true + ) + + if ($PackageInfo.Id -ne $ExpectedId) { + Fail "$($PackageInfo.File.Name) has package ID '$($PackageInfo.Id)', expected '$ExpectedId'." + } + + if ($PackageInfo.Version -ne $Version) { + Fail "$ExpectedId has version '$($PackageInfo.Version)', expected '$Version'." + } + + if ($RequireReadmeAndLicense) { + if ($PackageInfo.LicenseType -ne 'expression' -or $PackageInfo.License -ne 'MIT') { + Fail "$ExpectedId must use MIT license expression." + } + + if ($PackageInfo.Readme -ne 'README.md' -or 'README.md' -notin $PackageInfo.Entries) { + Fail "$ExpectedId must include README.md and reference it from the nuspec." + } + } + + if ($PackageInfo.ProjectUrl -ne $expectedRepositoryUrl) { + Fail "$ExpectedId projectUrl is '$($PackageInfo.ProjectUrl)', expected '$expectedRepositoryUrl'." + } + + if ($PackageInfo.RepositoryUrl -ne $RepositoryUrl) { + Fail "$ExpectedId repository URL is '$($PackageInfo.RepositoryUrl)', expected '$RepositoryUrl'." + } + + if ($PackageInfo.RepositoryCommit -ne $Commit) { + Fail "$ExpectedId repository commit is '$($PackageInfo.RepositoryCommit)', expected '$Commit'." + } + + if ($Branch -ne '' -and $PackageInfo.RepositoryBranch -ne $Branch) { + Fail "$ExpectedId repository branch is '$($PackageInfo.RepositoryBranch)', expected '$Branch'." + } +} + +if ($Version -ne $candidateVersion) { + Fail "This release gate only accepts version $candidateVersion; received '$Version'." +} + +if ($Version -eq '2.0.0' -or $Version -eq '3.0.0' -or $Version -notmatch '-') { + Fail "Version '$Version' is not allowed for this release candidate." +} + +if (-not (Test-Path -LiteralPath $PackageDirectory -PathType Container)) { + Fail "Package directory '$PackageDirectory' does not exist." +} + +if ([string]::IsNullOrWhiteSpace($Repository)) { + $Repository = $env:GITHUB_REPOSITORY +} + +if ([string]::IsNullOrWhiteSpace($RepositoryUrl)) { + $RepositoryUrl = $env:GITHUB_SERVER_URL + if (-not [string]::IsNullOrWhiteSpace($RepositoryUrl) -and -not [string]::IsNullOrWhiteSpace($Repository)) { + $RepositoryUrl = "$RepositoryUrl/$Repository" + } +} + +if ([string]::IsNullOrWhiteSpace($RepositoryUrl)) { + $RepositoryUrl = Get-GitOutput -Arguments @('config', '--get', 'remote.origin.url') + if ($RepositoryUrl -match '^git@github\.com:(.+)$') { + $RepositoryUrl = "https://github.com/$($Matches[1])" + } + + if ($RepositoryUrl -like '*.git') { + $RepositoryUrl = $RepositoryUrl.Substring(0, $RepositoryUrl.Length - 4) + } +} + +if ([string]::IsNullOrWhiteSpace($Repository)) { + $Repository = $RepositoryUrl +} + +if ([string]::IsNullOrWhiteSpace($Commit)) { + $Commit = $env:GITHUB_SHA +} + +if ([string]::IsNullOrWhiteSpace($Commit)) { + $Commit = Get-GitOutput -Arguments @('rev-parse', 'HEAD') +} + +if ([string]::IsNullOrWhiteSpace($Branch)) { + $Branch = $env:GITHUB_REF +} + +if ([string]::IsNullOrWhiteSpace($Branch)) { + $gitBranch = Get-GitOutput -Arguments @('rev-parse', '--abbrev-ref', 'HEAD') + if (-not [string]::IsNullOrWhiteSpace($gitBranch) -and $gitBranch -ne 'HEAD') { + $Branch = "refs/heads/$gitBranch" + } +} + +if ([string]::IsNullOrWhiteSpace($RepositoryUrl)) { + Fail 'Repository URL could not be determined.' +} + +if ([string]::IsNullOrWhiteSpace($Commit)) { + Fail 'Repository commit could not be determined.' +} + +if ($Branch -ne $candidateBranch) { + Fail "This release gate only accepts branch $candidateBranch; received '$Branch'." +} + +if ($RepositoryUrl -ne $expectedRepositoryUrl) { + Fail "Repository URL '$RepositoryUrl' is not the expected release repository '$expectedRepositoryUrl'." +} + +if ([string]::IsNullOrWhiteSpace($ManifestPath)) { + $ManifestPath = Join-Path $PackageDirectory 'release-artifact-manifest.json' +} + +Add-Type -AssemblyName System.IO.Compression.FileSystem + +$packageRoot = (Resolve-Path -LiteralPath $PackageDirectory).Path +$nupkgs = @(Get-ChildItem -LiteralPath $packageRoot -Filter '*.nupkg' -File | Sort-Object Name) +$snupkgs = @(Get-ChildItem -LiteralPath $packageRoot -Filter '*.snupkg' -File | Sort-Object Name) +$allArtifacts = @($nupkgs + $snupkgs) + +if ($nupkgs.Count -ne 5) { + Fail "Expected 5 .nupkg files, found $($nupkgs.Count)." +} + +if ($snupkgs.Count -ne 3) { + Fail "Expected 3 .snupkg files, found $($snupkgs.Count)." +} + +$expectedNupkgNames = @($expectedNupkgIds | ForEach-Object { "$_.$Version.nupkg" }) +$expectedSnupkgNames = @($expectedSnupkgIds | ForEach-Object { "$_.$Version.snupkg" }) +Assert-SetEquals -Expected $expectedNupkgNames -Actual ([string[]]@($nupkgs.Name)) -Description '.nupkg file set' +Assert-SetEquals -Expected $expectedSnupkgNames -Actual ([string[]]@($snupkgs.Name)) -Description '.snupkg file set' + +$forbiddenArtifacts = @($allArtifacts | Where-Object { $_.Name -match '(?i)(Tests|Benchmarks|AotSmoke)' }) +if ($forbiddenArtifacts.Count -gt 0) { + Fail "Unexpected test/benchmark/smoke artifacts: $($forbiddenArtifacts.Name -join ', ')." +} + +$packageInfos = @{} +foreach ($file in $nupkgs) { + $info = Get-ZipPackageInfo -File $file + Assert-CommonPackageMetadata -PackageInfo $info -ExpectedId ($file.Name.Substring(0, $file.Name.Length - ".$Version.nupkg".Length)) + Assert-Dependencies -PackageId $info.Id -Actual $info.Dependencies -Expected $expectedDependencies[$info.Id] + + if ($packageInfos.ContainsKey($info.Id)) { + Fail "Duplicate .nupkg package ID '$($info.Id)'." + } + + $packageInfos[$info.Id] = $info +} + +Assert-SetEquals -Expected $expectedNupkgIds -Actual ([string[]]$packageInfos.Keys) -Description '.nupkg package IDs' + +foreach ($id in @('Dapper.FluentMap', 'Dapper.FluentMap.Dommel', 'Dapper.FluentMap.DependencyInjection')) { + $expectedDll = "lib/netstandard2.0/$id.dll" + $expectedXml = "lib/netstandard2.0/$id.xml" + $info = $packageInfos[$id] + if ($expectedDll -notin $info.Entries -or $expectedXml -notin $info.Entries) { + Fail "$id must include $expectedDll and $expectedXml." + } +} + +foreach ($id in @('Dapper.FluentMap.Analyzers', 'Dapper.FluentMap.Generators')) { + $info = $packageInfos[$id] + $expectedDll = "analyzers/dotnet/cs/$id.dll" + $expectedPdb = "analyzers/dotnet/cs/$id.pdb" + if ($expectedDll -notin $info.Entries -or $expectedPdb -notin $info.Entries) { + Fail "$id must use analyzer package layout under analyzers/dotnet/cs." + } + + $libEntries = @($info.Entries | Where-Object { $_ -like 'lib/*' }) + if ($libEntries.Count -gt 0) { + Fail "$id must not include lib assets: $($libEntries -join ', ')." + } +} + +$symbolInfos = @{} +foreach ($file in $snupkgs) { + $expectedId = $file.Name.Substring(0, $file.Name.Length - ".$Version.snupkg".Length) + $info = Get-ZipPackageInfo -File $file + Assert-CommonPackageMetadata -PackageInfo $info -ExpectedId $expectedId -RequireReadmeAndLicense $false + + $expectedPdb = "lib/netstandard2.0/$expectedId.pdb" + if ($expectedPdb -notin $info.Entries) { + Fail "$expectedId symbol package must include $expectedPdb." + } + + if ($symbolInfos.ContainsKey($info.Id)) { + Fail "Duplicate .snupkg package ID '$($info.Id)'." + } + + $symbolInfos[$info.Id] = $info +} + +Assert-SetEquals -Expected $expectedSnupkgIds -Actual ([string[]]$symbolInfos.Keys) -Description '.snupkg package IDs' + +$manifestPackages = @( + foreach ($file in $allArtifacts | Sort-Object Name) { + $id = if ($file.Name.EndsWith('.snupkg', [System.StringComparison]::OrdinalIgnoreCase)) { + $file.Name.Substring(0, $file.Name.Length - ".$Version.snupkg".Length) + } + else { + $file.Name.Substring(0, $file.Name.Length - ".$Version.nupkg".Length) + } + + [ordered]@{ + file = $file.Name + packageId = $id + version = $Version + sha256 = (Get-FileHash -LiteralPath $file.FullName -Algorithm SHA256).Hash.ToLowerInvariant() + kind = if ($file.Extension -eq '.snupkg') { 'symbols' } else { 'package' } + size = $file.Length + } + } +) + +$manifest = [ordered]@{ + schemaVersion = '1.0' + version = $Version + repository = $Repository + repositoryUrl = $RepositoryUrl + commit = $Commit + branch = $Branch + packages = @($manifestPackages) +} + +$manifestDirectory = Split-Path -Parent $ManifestPath +if (-not [string]::IsNullOrWhiteSpace($manifestDirectory)) { + New-Item -ItemType Directory -Force -Path $manifestDirectory | Out-Null +} + +$manifestJson = $manifest | ConvertTo-Json -Depth 8 +[System.IO.File]::WriteAllText( + (Resolve-Path -LiteralPath (Split-Path -Parent $ManifestPath)).Path + [System.IO.Path]::DirectorySeparatorChar + (Split-Path -Leaf $ManifestPath), + $manifestJson + [Environment]::NewLine, + [System.Text.UTF8Encoding]::new($false)) + +$validatedManifest = Get-Content -Raw -Path $ManifestPath | ConvertFrom-Json +if ($validatedManifest.version -ne $Version -or @($validatedManifest.packages).Count -ne 8) { + Fail "Generated manifest '$ManifestPath' did not round-trip with the expected version and package count." +} + +Write-Host "Validated 5 .nupkg files, 3 .snupkg files and wrote manifest '$ManifestPath' for $Version." From 44f690195f9a06703e04c051411047b993644186 Mon Sep 17 00:00:00 2001 From: Rodrigo de Oliveira Date: Wed, 29 Jul 2026 14:40:50 -0300 Subject: [PATCH 45/49] fix(release): allow remote qualification on release branch --- .github/workflows/release.yml | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 3a3f417..ecb4cc4 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,6 +1,9 @@ name: Release on: + push: + branches: + - release/3.0.0-rc.1 workflow_dispatch: inputs: package-version: @@ -17,6 +20,8 @@ env: DOTNET_CLI_TELEMETRY_OPTOUT: "true" DOTNET_SKIP_FIRST_TIME_EXPERIENCE: "true" CI: "true" + PACKAGE_VERSION: ${{ inputs.package-version || '3.0.0-rc.1' }} + PUBLISH_REQUESTED: ${{ inputs.publish && 'true' || 'false' }} permissions: contents: read @@ -48,7 +53,7 @@ jobs: shell: pwsh run: | $ErrorActionPreference = 'Stop' - $version = '${{ inputs.package-version }}' + $version = '${{ env.PACKAGE_VERSION }}' if ($version -notmatch '^\d+\.\d+\.\d+(-[0-9A-Za-z][0-9A-Za-z.-]*)$') { throw "Package version '$version' is not a supported SemVer value." @@ -64,7 +69,7 @@ jobs: } - name: Guard disabled publish path - if: ${{ inputs.publish }} + if: ${{ env.PUBLISH_REQUESTED == 'true' }} shell: pwsh run: | $ErrorActionPreference = 'Stop' @@ -80,13 +85,13 @@ jobs: run: dotnet list ./Dapper.FluentMap.sln package --vulnerable --include-transitive - name: Build Release - run: dotnet build ./Dapper.FluentMap.sln --configuration Release --no-restore -p:Version=${{ inputs.package-version }} + run: dotnet build ./Dapper.FluentMap.sln --configuration Release --no-restore -p:Version=${{ env.PACKAGE_VERSION }} - name: Test Release run: dotnet test ./Dapper.FluentMap.sln --configuration Release --no-build - name: Pack Release - run: dotnet pack ./Dapper.FluentMap.sln --configuration Release --no-build --output ./artifacts/packages -p:Version=${{ inputs.package-version }} + run: dotnet pack ./Dapper.FluentMap.sln --configuration Release --no-build --output ./artifacts/packages -p:Version=${{ env.PACKAGE_VERSION }} - name: Validate release artifacts and write manifest shell: pwsh @@ -94,7 +99,7 @@ jobs: $ErrorActionPreference = 'Stop' ./eng/validate-release-artifacts.ps1 ` -PackageDirectory './artifacts/packages' ` - -Version '${{ inputs.package-version }}' ` + -Version '${{ env.PACKAGE_VERSION }}' ` -ManifestPath './artifacts/release-metadata/artifact-manifest.json' ` -Repository '${{ github.repository }}' ` -RepositoryUrl 'https://github.com/${{ github.repository }}' ` @@ -112,7 +117,7 @@ jobs: - name: Upload release package artifacts uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: - name: release-packages-${{ inputs.package-version }} + name: release-packages-${{ env.PACKAGE_VERSION }} path: | ./artifacts/packages/*.nupkg ./artifacts/packages/*.snupkg @@ -134,7 +139,7 @@ jobs: - name: Download release package artifacts uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6 with: - name: release-packages-${{ inputs.package-version }} + name: release-packages-${{ env.PACKAGE_VERSION }} path: ./artifacts/release - name: Collect package subjects From 5fc4b7c4ad67a07bed7d4e29ff9098b48c4fc41c Mon Sep 17 00:00:00 2001 From: Rodrigo de Oliveira Date: Wed, 29 Jul 2026 14:51:36 -0300 Subject: [PATCH 46/49] docs(release): record remote rc qualification --- .../03-remote-qualification.md | 158 ++++++++++++++++++ .sdd/release-3.0.0-rc.1/STATUS.md | 55 ++++-- .sdd/release-3.0.0-rc.1/artifacts.json | 87 ++++++++++ 3 files changed, 285 insertions(+), 15 deletions(-) create mode 100644 .sdd/release-3.0.0-rc.1/03-remote-qualification.md create mode 100644 .sdd/release-3.0.0-rc.1/artifacts.json diff --git a/.sdd/release-3.0.0-rc.1/03-remote-qualification.md b/.sdd/release-3.0.0-rc.1/03-remote-qualification.md new file mode 100644 index 0000000..48a0b96 --- /dev/null +++ b/.sdd/release-3.0.0-rc.1/03-remote-qualification.md @@ -0,0 +1,158 @@ +# Remote Qualification + +## Branch + +- Branch enviada: `release/3.0.0-rc.1`. +- Remote usado para push: `origin` com push URL HTTPS + `https://github.com/rodri-oliveira-dev/Dapper-FluentMap.git`. +- O remote fetch local continua configurado como SSH, mas a autenticacao SSH + falhou com `Permission denied (publickey)`. O push foi feito via HTTPS + autenticado pelo GitHub CLI. +- Primeiro push controlado: `e6e462782c0151763679fc7802518b8026333d54` + (`ci(release): qualify 3.0.0-rc.1 artifacts`), sem `--force`. +- Segundo push necessario: `44f690195f9a06703e04c051411047b993644186` + (`fix(release): allow remote qualification on release branch`), sem + `--force`, para permitir execucao remota do workflow de release por `push`. + +## Commit + +Commit qualificado remotamente: +`44f690195f9a06703e04c051411047b993644186`. + +O commit esperado do RC.2 (`e6e462782c0151763679fc7802518b8026333d54`) foi +enviado primeiro. A qualificacao remota final usa `44f690195...` porque o +workflow `release.yml` nao era dispatchable enquanto inexistente na default +branch, exigindo uma correcao de workflow na propria branch de release. + +## Workflow run + +- Workflow: `Release`. +- Run ID: `30476842589`. +- Attempt: `1`. +- Evento: `push`. +- URL: `https://github.com/rodri-oliveira-dev/Dapper-FluentMap/actions/runs/30476842589`. +- Criado em: `2026-07-29T17:46:13Z`. +- Concluido em: `2026-07-29T17:47:52Z`. +- Resultado: `success`. +- Jobs: + - `Validate release package`: `success`. + - `Attest release package provenance`: `success`. + +## Version + +Versao qualificada: `3.0.0-rc.1`. + +O workflow validou a versao fixa `3.0.0-rc.1`, rejeitou publish por padrao e +executou build, test, pack, validacao de artifacts, inventory de dependencias e +attestation sem publicacao NuGet. + +## Artifacts + +- Artifact remoto: `release-packages-3.0.0-rc.1`. +- Artifact ID: `8733989011`. +- Run ID: `30476842589`. +- Baixado localmente em: + `artifacts/release-3.0.0-rc.1/remote/`. +- Conteudo baixado: + - 5 `.nupkg`; + - 3 `.snupkg`; + - `artifact-manifest.json`; + - `dependencies.json`. + +## Package hashes + +Os hashes SHA-256 abaixo foram recalculados nos artifacts remotos baixados e +comparados com `artifact-manifest.json`. + +| Artifact | SHA-256 | +| --- | --- | +| `Dapper.FluentMap.3.0.0-rc.1.nupkg` | `55059c450db16a28d8e058460571950bcf967a88e4435a519a82612609f6407f` | +| `Dapper.FluentMap.3.0.0-rc.1.snupkg` | `9c1e96ba9f0760311280b4b6ffefce05a7f8d0dbe38844fefc4c9949711f90f7` | +| `Dapper.FluentMap.Analyzers.3.0.0-rc.1.nupkg` | `0b9e4c01bce2ef772b441124a03df554637a729c71912242f7ca28cfec8576fb` | +| `Dapper.FluentMap.DependencyInjection.3.0.0-rc.1.nupkg` | `339cabaea4399aa4d0387794a03910d4348c1ce46fa001aaa4a9ceb35f7a8785` | +| `Dapper.FluentMap.DependencyInjection.3.0.0-rc.1.snupkg` | `866400c82b10f9d4655bd03e0ae94ef99c70bb7da68f9ee9a675815ff6d11065` | +| `Dapper.FluentMap.Dommel.3.0.0-rc.1.nupkg` | `9c4b2157cf5f65b17e8914077921e1d23836adebb7f2fce2a6ff6f6673c8b680` | +| `Dapper.FluentMap.Dommel.3.0.0-rc.1.snupkg` | `b249b91f0764664a4e5f60161d082374ce1d22215c8d977cd886b0ced258a3bf` | +| `Dapper.FluentMap.Generators.3.0.0-rc.1.nupkg` | `9fcfc0c4586e35c408b49e964c1ed622cfc49192089da466809c721553920629` | + +## SourceLink + +SourceLink foi validado com `sourcelink` `3.1.1`. + +- SourceLink URL template: + `https://raw.githubusercontent.com/rodri-oliveira-dev/Dapper-FluentMap/44f690195f9a06703e04c051411047b993644186/*`. +- Commit remoto confirmado: + `44f690195f9a06703e04c051411047b993644186`. +- `sourcelink test` passou para: + - `Dapper.FluentMap.pdb`; + - `Dapper.FluentMap.DependencyInjection.pdb`; + - `Dapper.FluentMap.Dommel.pdb`; + - `Dapper.FluentMap.Analyzers.pdb`; + - `Dapper.FluentMap.Generators.pdb`. +- A ferramenta baixou os arquivos fonte via `raw.githubusercontent.com` e + validou checksums SHA-256 dos documentos registrados nos PDBs. +- Os PDBs de core, DI e Dommel foram obtidos dos `.snupkg`; os PDBs de analyzer + e generator foram obtidos dos respectivos `.nupkg`. + +## Provenance + +Provenance foi validada com GitHub artifact attestations, predicado +`https://slsa.dev/provenance/v1`. + +- Verificacao executada com `gh attestation verify` para cada um dos 8 + artifacts. +- Repositorio exigido: `rodri-oliveira-dev/Dapper-FluentMap`. +- Source ref exigido: `refs/heads/release/3.0.0-rc.1`. +- Source digest exigido: + `44f690195f9a06703e04c051411047b993644186`. +- Cert identity exigida: + `https://github.com/rodri-oliveira-dev/Dapper-FluentMap/.github/workflows/release.yml@refs/heads/release/3.0.0-rc.1`. +- Issuer OIDC: `https://token.actions.githubusercontent.com`. +- Workflow: `Release`. +- Runner: `github-hosted`. +- Invocation: + `https://github.com/rodri-oliveira-dev/Dapper-FluentMap/actions/runs/30476842589/attempts/1`. +- Timestamp verificado via Rekor: `2026-07-29T14:47:50-03:00`. +- A attestation contem 8 subjects e todos os artifacts baixados possuem subject + correspondente com SHA-256 identico. + +Os bundles de attestation foram baixados apenas como evidencia local +nao versionada em `artifacts/release-3.0.0-rc.1/remote/attestations/`. + +## Security + +- `publish` permaneceu desabilitado; o step `Guard disabled publish path` foi + ignorado porque publish nao foi solicitado. +- Nenhum package foi publicado. +- Nenhuma tag foi criada. +- Nenhum GitHub Release foi criado. +- Nenhum merge em `master` foi executado. +- Os arquivos SDD versionados nao incluem tokens nem URLs assinadas temporarias. +- Permissoes do workflow: + - job de validacao: `contents: read`; + - job de provenance: `contents: read`, `id-token: write`, + `attestations: write`. +- O run apresentou uma anotacao informativa de Actions sobre deprecacao de + Node.js 20 para `actions/download-artifact`; nao bloqueou a qualificacao. + +## Failures and retries + +1. `gh workflow run .github/workflows/release.yml --ref release/3.0.0-rc.1` + falhou com HTTP 404 porque o workflow `release.yml` nao existe na default + branch. +2. Falha classificada como falha de acionamento remoto do workflow, nao falha de + build, test, pack, SourceLink ou provenance. +3. Correcao aplicada no escopo de workflow: + `fix(release): allow remote qualification on release branch`. +4. A branch foi reenviada sem `--force`; o evento `push` criou o run + `30476842589`. +5. O run terminal passou sem retries adicionais. + +## Result + +Remote qualification: Passed with limitations. + +Limitacao restante para RC.4: enquanto `release.yml` nao existir na default +branch ou nao houver outro caminho de promocao definido, `workflow_dispatch` do +workflow de release pelo GitHub CLI/UI continua indisponivel. A qualificacao +remota desta RC foi executada por `push` na branch de release. diff --git a/.sdd/release-3.0.0-rc.1/STATUS.md b/.sdd/release-3.0.0-rc.1/STATUS.md index cce4008..3e7dcb7 100644 --- a/.sdd/release-3.0.0-rc.1/STATUS.md +++ b/.sdd/release-3.0.0-rc.1/STATUS.md @@ -2,25 +2,31 @@ ## Estado -RC.2 qualificado localmente. O workflow de release agora atua como gate seguro -para gerar exatamente `3.0.0-rc.1`, validar artefatos, gerar manifest com -checksums e preparar provenance sem publicar. +RC.3 qualificado remotamente com limitacoes. Os artifacts `3.0.0-rc.1` foram +gerados no GitHub Actions a partir de commit disponivel no remoto, baixados, +validados com o mesmo script do RC.2, conferidos contra manifest, SourceLink e +GitHub artifact attestations. ## Commit candidato atual Base inicial: `15e926c` (`chore(release): complete FluentMap readiness audit`). RC.1 local: `a71d0213976c51437e1301bbaae699e4b4519c1d` (`chore(release): prepare versioning for 3.0.0-rc.1`). -O commit que deve ser usado na qualificacao remota do Prompt RC.3 e o commit -final do Prompt RC.2, com mensagem -`ci(release): qualify 3.0.0-rc.1 artifacts`. O hash exato sera obtido apos a -criacao do commit, porque registra-lo dentro do proprio commit alteraria o hash. +RC.2 local: `e6e462782c0151763679fc7802518b8026333d54` +(`ci(release): qualify 3.0.0-rc.1 artifacts`). +RC.3 remoto qualificado: `44f690195f9a06703e04c051411047b993644186` +(`fix(release): allow remote qualification on release branch`). + +O commit RC.3 contem somente correcao de workflow necessaria porque +`release.yml` nao podia ser executado por `workflow_dispatch` enquanto nao +existente na default branch. ## Branch - Origem: `feature/etapa-3`. - Atual/final esperada: `release/3.0.0-rc.1`. -- Nenhum push executado neste prompt. +- Branch remota: `origin/release/3.0.0-rc.1`. +- Push executado neste prompt somente para a branch de release, sem `--force`. ## Concluido @@ -47,6 +53,18 @@ criacao do commit, porque registra-lo dentro do proprio commit alteraria o hash. README, license MIT e layouts analyzer/generator. - Gate local RC.2 executado: restore, audit, build, test da solution, provider SQLite, pack, manifest, YAML parse e `git diff --check`. +- Push controlado da branch `release/3.0.0-rc.1`. +- Workflow remoto `Release` executado no run `30476842589`. +- Artifacts remotos baixados em `artifacts/release-3.0.0-rc.1/remote/`. +- Script `eng/validate-release-artifacts.ps1` passou contra os artifacts + remotos. +- Manifest remoto comparado com manifest recalculado localmente e SHA-256 + confirmados. +- SourceLink validado nos PDBs de core, DI, Dommel, analyzers e generators. +- GitHub artifact attestations validadas com predicado SLSA provenance v1, + cert identity, repository, source ref, source digest e subjects/hashes. +- Relatorio `.sdd/release-3.0.0-rc.1/03-remote-qualification.md` criado. +- Manifest versionado `.sdd/release-3.0.0-rc.1/artifacts.json` criado. ## Em andamento @@ -54,14 +72,16 @@ criacao do commit, porque registra-lo dentro do proprio commit alteraria o hash. ## Proximos passos -1. Revisar `git diff` e `git diff --check`. -2. Criar o commit `chore(release): prepare versioning for 3.0.0-rc.1`. -3. Em prompt futuro, executar workflow remoto apos push autorizado. +1. Revisar o commit de evidencia do RC.3. +2. Definir no RC.4 o caminho de promocao/publicacao sem publicar + automaticamente. ## RC blockers -- SourceLink/provenance ainda precisam ser validados em SHA remoto apos push do - commit RC.2. +- Remote qualification: Passed with limitations. +- `workflow_dispatch` de `release.yml` continua indisponivel ate o workflow + existir na default branch ou outro caminho de promocao ser definido. A + qualificacao RC.3 foi executada por evento `push` na branch de release. - Consumer smoke externo com os cinco pacotes RC ainda nao foi executado. ## Stable-only blockers @@ -81,12 +101,17 @@ criacao do commit, porque registra-lo dentro do proprio commit alteraria o hash. `3.0.0-rc.1`. - RC.2 local: `artifacts/release-3.0.0-rc.1/rc2-local`, com 5 `.nupkg`, 3 `.snupkg`, `artifact-manifest.json` e `dependencies.json`. +- RC.3 remoto: `artifacts/release-3.0.0-rc.1/remote`, com 5 `.nupkg`, 3 + `.snupkg`, `artifact-manifest.json`, `dependencies.json`, PDBs extraidos para + SourceLink e bundles de attestation locais nao versionados. - Bloqueios negativos confirmados para `2.0.0` e stable `3.0.0`. ## Workflow runs -- Nenhum workflow remoto executado neste prompt. +- `30476842589`: `Release`, branch `release/3.0.0-rc.1`, commit + `44f690195f9a06703e04c051411047b993644186`, evento `push`, resultado + `success`. ## Ultimo prompt executado -Ultimo prompt executado: RC.2 +Ultimo prompt executado: RC.3 diff --git a/.sdd/release-3.0.0-rc.1/artifacts.json b/.sdd/release-3.0.0-rc.1/artifacts.json new file mode 100644 index 0000000..0e2f431 --- /dev/null +++ b/.sdd/release-3.0.0-rc.1/artifacts.json @@ -0,0 +1,87 @@ +{ + "schemaVersion": "1.0", + "release": "3.0.0-rc.1", + "repository": "rodri-oliveira-dev/Dapper-FluentMap", + "repositoryUrl": "https://github.com/rodri-oliveira-dev/Dapper-FluentMap", + "branch": "release/3.0.0-rc.1", + "ref": "refs/heads/release/3.0.0-rc.1", + "commit": "44f690195f9a06703e04c051411047b993644186", + "workflow": { + "name": "Release", + "workflowRunId": 30476842589, + "workflowRunAttempt": 1, + "workflowRunUrl": "https://github.com/rodri-oliveira-dev/Dapper-FluentMap/actions/runs/30476842589", + "artifactId": 8733989011, + "artifactName": "release-packages-3.0.0-rc.1" + }, + "artifacts": [ + { + "name": "Dapper.FluentMap.3.0.0-rc.1.nupkg", + "packageId": "Dapper.FluentMap", + "version": "3.0.0-rc.1", + "kind": "package", + "sha256": "55059c450db16a28d8e058460571950bcf967a88e4435a519a82612609f6407f" + }, + { + "name": "Dapper.FluentMap.3.0.0-rc.1.snupkg", + "packageId": "Dapper.FluentMap", + "version": "3.0.0-rc.1", + "kind": "symbols", + "sha256": "9c1e96ba9f0760311280b4b6ffefce05a7f8d0dbe38844fefc4c9949711f90f7" + }, + { + "name": "Dapper.FluentMap.Analyzers.3.0.0-rc.1.nupkg", + "packageId": "Dapper.FluentMap.Analyzers", + "version": "3.0.0-rc.1", + "kind": "package", + "sha256": "0b9e4c01bce2ef772b441124a03df554637a729c71912242f7ca28cfec8576fb" + }, + { + "name": "Dapper.FluentMap.DependencyInjection.3.0.0-rc.1.nupkg", + "packageId": "Dapper.FluentMap.DependencyInjection", + "version": "3.0.0-rc.1", + "kind": "package", + "sha256": "339cabaea4399aa4d0387794a03910d4348c1ce46fa001aaa4a9ceb35f7a8785" + }, + { + "name": "Dapper.FluentMap.DependencyInjection.3.0.0-rc.1.snupkg", + "packageId": "Dapper.FluentMap.DependencyInjection", + "version": "3.0.0-rc.1", + "kind": "symbols", + "sha256": "866400c82b10f9d4655bd03e0ae94ef99c70bb7da68f9ee9a675815ff6d11065" + }, + { + "name": "Dapper.FluentMap.Dommel.3.0.0-rc.1.nupkg", + "packageId": "Dapper.FluentMap.Dommel", + "version": "3.0.0-rc.1", + "kind": "package", + "sha256": "9c4b2157cf5f65b17e8914077921e1d23836adebb7f2fce2a6ff6f6673c8b680" + }, + { + "name": "Dapper.FluentMap.Dommel.3.0.0-rc.1.snupkg", + "packageId": "Dapper.FluentMap.Dommel", + "version": "3.0.0-rc.1", + "kind": "symbols", + "sha256": "b249b91f0764664a4e5f60161d082374ce1d22215c8d977cd886b0ced258a3bf" + }, + { + "name": "Dapper.FluentMap.Generators.3.0.0-rc.1.nupkg", + "packageId": "Dapper.FluentMap.Generators", + "version": "3.0.0-rc.1", + "kind": "package", + "sha256": "9fcfc0c4586e35c408b49e964c1ed622cfc49192089da466809c721553920629" + } + ], + "validation": { + "remoteArtifactManifestMatchesDownloadedFiles": true, + "releaseArtifactScript": "Passed", + "sourceLink": "Passed", + "provenance": "Passed", + "security": "Passed", + "result": "Passed with limitations" + }, + "limitations": [ + "The first workflow_dispatch attempt failed because release.yml was not present on the default branch. Remote qualification was rerun by a push-triggered Release workflow on release/3.0.0-rc.1 after a workflow-only correction commit.", + "GitHub CLI attestation download is marked public preview by gh." + ] +} From 9443f7a301e720967a330799586f4fcc4c84334b Mon Sep 17 00:00:00 2001 From: Rodrigo de Oliveira Date: Wed, 29 Jul 2026 15:12:52 -0300 Subject: [PATCH 47/49] test(packages): validate rc package consumption --- .github/workflows/release.yml | 9 +- .sdd/release-3.0.0-rc.1/04-consumer-smoke.md | 198 ++++++ .sdd/release-3.0.0-rc.1/STATUS.md | 35 +- .../AnalyzerDiagnosticConsumer.csproj | 13 + .../AnalyzerDiagnosticConsumer/Program.cs | 21 + .../CoreConsumer/CoreConsumer.csproj | 14 + eng/consumer-smoke/CoreConsumer/Program.cs | 254 ++++++++ .../DIConsumer/DIConsumer.csproj | 16 + eng/consumer-smoke/DIConsumer/Program.cs | 125 ++++ .../DommelConsumer/DommelConsumer.csproj | 14 + eng/consumer-smoke/DommelConsumer/Program.cs | 211 +++++++ .../GeneratorAnalyzerConsumer.csproj | 18 + .../GeneratorAnalyzerConsumer/Program.cs | 132 ++++ .../TrimExplicitConsumer/Program.cs | 36 ++ .../TrimExplicitConsumer.csproj | 14 + .../TrimGeneratedConsumer/Program.cs | 36 ++ .../TrimGeneratedConsumer.csproj | 15 + eng/consumer-smoke/run-consumer-smoke.ps1 | 570 ++++++++++++++++++ 18 files changed, 1722 insertions(+), 9 deletions(-) create mode 100644 .sdd/release-3.0.0-rc.1/04-consumer-smoke.md create mode 100644 eng/consumer-smoke/AnalyzerDiagnosticConsumer/AnalyzerDiagnosticConsumer.csproj create mode 100644 eng/consumer-smoke/AnalyzerDiagnosticConsumer/Program.cs create mode 100644 eng/consumer-smoke/CoreConsumer/CoreConsumer.csproj create mode 100644 eng/consumer-smoke/CoreConsumer/Program.cs create mode 100644 eng/consumer-smoke/DIConsumer/DIConsumer.csproj create mode 100644 eng/consumer-smoke/DIConsumer/Program.cs create mode 100644 eng/consumer-smoke/DommelConsumer/DommelConsumer.csproj create mode 100644 eng/consumer-smoke/DommelConsumer/Program.cs create mode 100644 eng/consumer-smoke/GeneratorAnalyzerConsumer/GeneratorAnalyzerConsumer.csproj create mode 100644 eng/consumer-smoke/GeneratorAnalyzerConsumer/Program.cs create mode 100644 eng/consumer-smoke/TrimExplicitConsumer/Program.cs create mode 100644 eng/consumer-smoke/TrimExplicitConsumer/TrimExplicitConsumer.csproj create mode 100644 eng/consumer-smoke/TrimGeneratedConsumer/Program.cs create mode 100644 eng/consumer-smoke/TrimGeneratedConsumer/TrimGeneratedConsumer.csproj create mode 100644 eng/consumer-smoke/run-consumer-smoke.ps1 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ecb4cc4..71b064e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -34,7 +34,7 @@ jobs: validate-package: name: Validate release package runs-on: ubuntu-latest - timeout-minutes: 45 + timeout-minutes: 60 steps: - name: Checkout @@ -106,6 +106,13 @@ jobs: -Commit '${{ github.sha }}' ` -Branch '${{ github.ref }}' + - name: Consumer smoke release packages + shell: pwsh + run: | + $ErrorActionPreference = 'Stop' + ./eng/consumer-smoke/run-consumer-smoke.ps1 ` + -PackageDirectory './artifacts/packages' + - name: Write dependency inventory shell: pwsh run: | diff --git a/.sdd/release-3.0.0-rc.1/04-consumer-smoke.md b/.sdd/release-3.0.0-rc.1/04-consumer-smoke.md new file mode 100644 index 0000000..4330250 --- /dev/null +++ b/.sdd/release-3.0.0-rc.1/04-consumer-smoke.md @@ -0,0 +1,198 @@ +# Consumer Smoke + +## Package source + +Artifacts remotos registrados em `.sdd/release-3.0.0-rc.1/artifacts.json`. + +- Workflow run: `30476842589` +- Artifact remoto: `release-packages-3.0.0-rc.1` +- Commit dos packages: `44f690195f9a06703e04c051411047b993644186` +- Pasta local validada: `artifacts/release-3.0.0-rc.1/remote/packages` +- Feed NuGet temporario criado pelo smoke: + `.tmp/consumer-smoke/feed` +- Cache NuGet temporario criado pelo smoke: + `.tmp/consumer-smoke/packages` + +O script `eng/consumer-smoke/run-consumer-smoke.ps1` recria a pasta +`.tmp/consumer-smoke`, copia somente os `.nupkg` remotos validados para o feed +temporario, usa `NuGet.Config` temporario com `packageSourceMapping`, restaura +com `RestorePackagesPath` temporario e `RestoreNoCache=true`, e bloqueia +`ProjectReference`/referencias diretas a assemblies locais nos consumers. + +Os artifacts locais ja existiam; nao foi necessario baixar novamente via +`gh run download`. O script possui fallback de download pelo workflow run +registrado quando os artifacts esperados nao estiverem presentes. + +## Package hashes + +Hashes SHA-256 recalculados e comparados com +`.sdd/release-3.0.0-rc.1/artifacts.json` antes do smoke: + +| Artifact | SHA-256 | +| --- | --- | +| `Dapper.FluentMap.3.0.0-rc.1.nupkg` | `55059c450db16a28d8e058460571950bcf967a88e4435a519a82612609f6407f` | +| `Dapper.FluentMap.3.0.0-rc.1.snupkg` | `9c1e96ba9f0760311280b4b6ffefce05a7f8d0dbe38844fefc4c9949711f90f7` | +| `Dapper.FluentMap.Analyzers.3.0.0-rc.1.nupkg` | `0b9e4c01bce2ef772b441124a03df554637a729c71912242f7ca28cfec8576fb` | +| `Dapper.FluentMap.DependencyInjection.3.0.0-rc.1.nupkg` | `339cabaea4399aa4d0387794a03910d4348c1ce46fa001aaa4a9ceb35f7a8785` | +| `Dapper.FluentMap.DependencyInjection.3.0.0-rc.1.snupkg` | `866400c82b10f9d4655bd03e0ae94ef99c70bb7da68f9ee9a675815ff6d11065` | +| `Dapper.FluentMap.Dommel.3.0.0-rc.1.nupkg` | `9c4b2157cf5f65b17e8914077921e1d23836adebb7f2fce2a6ff6f6673c8b680` | +| `Dapper.FluentMap.Dommel.3.0.0-rc.1.snupkg` | `b249b91f0764664a4e5f60161d082374ce1d22215c8d977cd886b0ced258a3bf` | +| `Dapper.FluentMap.Generators.3.0.0-rc.1.nupkg` | `9fcfc0c4586e35c408b49e964c1ed622cfc49192089da466809c721553920629` | + +## Scenarios + +- Core console consumer: restore, build, run. +- Generator/analyzer valid console consumer: restore, build, generated source + file check, run. +- Analyzer diagnostic console consumer: restore, build expected to fail with + `DFM001`. +- Dependency Injection console consumer: restore, build, run. +- Dommel console consumer: restore, build, run. +- Trimming explicit consumer: restore for RID, `dotnet publish + -p:PublishTrimmed=true`, run published binary. +- Trimming generated consumer: restore for RID, `dotnet publish + -p:PublishTrimmed=true`, run published binary. +- Dependency inspection: assets file scan for exact RC versions, absence of + project libraries, absence of direct source references, analyzer/generator + packages without runtime assets, no `2.0.0` packages. +- CI mode check: script executed with `-PackageDirectory` to validate the path + used by the release workflow. + +## Core consumer + +Result: Passed. + +Validated with `eng/consumer-smoke/CoreConsumer`: + +- install of `Dapper.FluentMap 3.0.0-rc.1`; +- explicit configuration with `AddMap()`; +- legacy API compatibility through `FluentMapper.EntityMaps` and + `FluentMapper.GetEntityMaps()`; +- Dapper root column mapping through `QuerySingle()`; +- `QueryMapped()`; +- constructor mapping; +- nested object materialization; +- immutable/value object materialization; +- profile query through `QueryMappedSingle()`; +- read converter through `ConvertFromDatabaseUsing()`; +- isolated runtime through `FluentMapConfigurationBuilder`. + +## Generator/analyzer consumer + +Result: Passed. + +Validated with `eng/consumer-smoke/GeneratorAnalyzerConsumer` and +`eng/consumer-smoke/AnalyzerDiagnosticConsumer`: + +- `Dapper.FluentMap.Generators 3.0.0-rc.1` loads as a compiler analyzer from + the package layout without manual DLL reference; +- generated extension `AddGeneratedMappings()` compiles; +- generated registration captures generated materializer metadata; +- generated materialization works for flat, nested, value object and read + converter scenarios; +- `Dapper.FluentMap.Analyzers 3.0.0-rc.1` emits known diagnostic `DFM001`; +- analyzer/generator packages have no runtime assets in `project.assets.json`; +- restore/build succeeded without PDB/layout issues. + +## DI consumer + +Result: Passed. + +Validated with `eng/consumer-smoke/DIConsumer`: + +- `ServiceCollection`; +- `AddFluentMap`; +- explicit map registration; +- generated registration through `builder.Configure(c => c.AddGeneratedMappings())`; +- `ImmutableFluentMapConfiguration` and `FluentMapRuntime` resolution; +- real SQLite query through resolved runtime; +- two isolated DI configurations using separate runtime instances. + +## Dommel consumer + +Result: Passed. + +Validated with `eng/consumer-smoke/DommelConsumer` using SQLite: + +- `Insert`; +- `Update`; +- non-identity key with `IsKey().SetGeneratedOption(DatabaseGeneratedOption.None)`; +- identity key with `IsIdentity()`; +- database default via `DatabaseDefaultOnInsert()`; +- computed column via `Computed()`; +- read-only column via `ReadOnly()`; +- ignored column via `Ignore()`; +- read-after-write through raw Dapper query and `Dommel.Get()`. + +## Trimming consumer + +Result: Passed. + +Executed locally on `win-x64`: + +```bash +dotnet publish ./eng/consumer-smoke/TrimExplicitConsumer/TrimExplicitConsumer.csproj --configuration Release --runtime win-x64 --self-contained true -p:PublishTrimmed=true +dotnet publish ./eng/consumer-smoke/TrimGeneratedConsumer/TrimGeneratedConsumer.csproj --configuration Release --runtime win-x64 --self-contained true -p:PublishTrimmed=true +``` + +Both published binaries were executed successfully. + +Observed trimming warnings: none. + +The release workflow will run the same script on `ubuntu-latest`, where the +script resolves the current RID dynamically, expected as `linux-x64` on the +current hosted runner. + +## Results + +Overall result: Passed. + +Package classification: + +| Package | Result | +| --- | --- | +| `Dapper.FluentMap 3.0.0-rc.1` | Passed | +| `Dapper.FluentMap.Dommel 3.0.0-rc.1` | Passed | +| `Dapper.FluentMap.DependencyInjection 3.0.0-rc.1` | Passed | +| `Dapper.FluentMap.Analyzers 3.0.0-rc.1` | Passed | +| `Dapper.FluentMap.Generators 3.0.0-rc.1` | Passed | + +Resolved package versions observed in consumer `project.assets.json`: + +- `Dapper.FluentMap`: `3.0.0-rc.1` +- `Dapper.FluentMap.Dommel`: `3.0.0-rc.1` +- `Dapper.FluentMap.DependencyInjection`: `3.0.0-rc.1` +- `Dapper.FluentMap.Analyzers`: `3.0.0-rc.1` +- `Dapper.FluentMap.Generators`: `3.0.0-rc.1` +- `Dapper`: `2.1.79` +- `Dommel`: `3.5.3` + +No `ProjectReference` was present in public FluentMap package consumers. No +`2.0.0` package was restored. Roslyn analyzer/generator packages did not appear +as runtime assets. + +CI integration: + +- `.github/workflows/release.yml` now runs + `./eng/consumer-smoke/run-consumer-smoke.ps1 -PackageDirectory './artifacts/packages'` + after release artifact validation and before dependency inventory/upload + completion. +- Release job timeout increased from 45 to 60 minutes to account for + self-contained trimmed publish. + +## Failures + +No product/package failures. + +Harness adjustments made during implementation: + +- Valid consumers changed nested lambda expressions from `Address!.City` to + `Address.City`, because the analyzer correctly reports `DFM001` for null + forgiveness in map expressions. +- DI isolation types were separated because the generator correctly reports + `DFM007` for multiple generated default maps targeting the same entity in one + compilation. + +## Blockers + +No RC.5 blockers from consumer smoke. diff --git a/.sdd/release-3.0.0-rc.1/STATUS.md b/.sdd/release-3.0.0-rc.1/STATUS.md index 3e7dcb7..e1e0c0d 100644 --- a/.sdd/release-3.0.0-rc.1/STATUS.md +++ b/.sdd/release-3.0.0-rc.1/STATUS.md @@ -2,10 +2,10 @@ ## Estado -RC.3 qualificado remotamente com limitacoes. Os artifacts `3.0.0-rc.1` foram -gerados no GitHub Actions a partir de commit disponivel no remoto, baixados, -validados com o mesmo script do RC.2, conferidos contra manifest, SourceLink e -GitHub artifact attestations. +RC.4 consumer smoke concluido. Os packages `3.0.0-rc.1` registrados no RC.3 +foram consumidos fora da solution do FluentMap a partir de feed NuGet local +temporario formado exclusivamente pelos artifacts remotos validados, com +NuGet.org apenas para dependencias externas. ## Commit candidato atual @@ -65,6 +65,17 @@ existente na default branch. cert identity, repository, source ref, source digest e subjects/hashes. - Relatorio `.sdd/release-3.0.0-rc.1/03-remote-qualification.md` criado. - Manifest versionado `.sdd/release-3.0.0-rc.1/artifacts.json` criado. +- Especificacao e evidencia + `.sdd/release-3.0.0-rc.1/04-consumer-smoke.md` criada. +- Infraestrutura `eng/consumer-smoke/` criada para consumers externos por + pacote, sem `ProjectReference`. +- Consumer smoke validou core, analyzer, generator, Dependency Injection, + Dommel e trimming usando os packages RC restaurados em versao exata. +- Dependency inspection confirmou ausencia de referencias ao codigo-fonte + local, ausencia de dependencias Roslyn runtime indevidas, resolucao esperada + de `Dapper 2.1.79` e `Dommel 3.5.3`, e nenhum package `2.0.0`. +- Workflow `Release` passou a executar consumer smoke como gate apos validacao + de artifacts. ## Em andamento @@ -72,8 +83,8 @@ existente na default branch. ## Proximos passos -1. Revisar o commit de evidencia do RC.3. -2. Definir no RC.4 o caminho de promocao/publicacao sem publicar +1. Revisar o commit de evidencia do RC.4. +2. Prosseguir para RC.5 com decisao de promocao/publicacao sem publicar automaticamente. ## RC blockers @@ -82,7 +93,15 @@ existente na default branch. - `workflow_dispatch` de `release.yml` continua indisponivel ate o workflow existir na default branch ou outro caminho de promocao ser definido. A qualificacao RC.3 foi executada por evento `push` na branch de release. -- Consumer smoke externo com os cinco pacotes RC ainda nao foi executado. +- Consumer smoke externo com os cinco pacotes RC: Passed. + +## Package consumer classification + +- `Dapper.FluentMap`: Passed. +- `Dapper.FluentMap.Dommel`: Passed. +- `Dapper.FluentMap.DependencyInjection`: Passed. +- `Dapper.FluentMap.Analyzers`: Passed. +- `Dapper.FluentMap.Generators`: Passed. ## Stable-only blockers @@ -114,4 +133,4 @@ existente na default branch. ## Ultimo prompt executado -Ultimo prompt executado: RC.3 +Ultimo prompt executado: RC.4 diff --git a/eng/consumer-smoke/AnalyzerDiagnosticConsumer/AnalyzerDiagnosticConsumer.csproj b/eng/consumer-smoke/AnalyzerDiagnosticConsumer/AnalyzerDiagnosticConsumer.csproj new file mode 100644 index 0000000..427dde2 --- /dev/null +++ b/eng/consumer-smoke/AnalyzerDiagnosticConsumer/AnalyzerDiagnosticConsumer.csproj @@ -0,0 +1,13 @@ + + + Exe + net10.0 + false + enable + enable + + + + + + diff --git a/eng/consumer-smoke/AnalyzerDiagnosticConsumer/Program.cs b/eng/consumer-smoke/AnalyzerDiagnosticConsumer/Program.cs new file mode 100644 index 0000000..a171096 --- /dev/null +++ b/eng/consumer-smoke/AnalyzerDiagnosticConsumer/Program.cs @@ -0,0 +1,21 @@ +using Dapper.FluentMap.Mapping; + +Console.WriteLine(typeof(InvalidCustomerMap).Name); + +public sealed class InvalidCustomer +{ + public string Name { get; set; } = string.Empty; + + public string GetName() + { + return Name; + } +} + +public sealed class InvalidCustomerMap : EntityMap +{ + public InvalidCustomerMap() + { + Map(customer => customer.GetName()).ToColumn("customer_name"); + } +} diff --git a/eng/consumer-smoke/CoreConsumer/CoreConsumer.csproj b/eng/consumer-smoke/CoreConsumer/CoreConsumer.csproj new file mode 100644 index 0000000..af9ee4e --- /dev/null +++ b/eng/consumer-smoke/CoreConsumer/CoreConsumer.csproj @@ -0,0 +1,14 @@ + + + Exe + net10.0 + false + enable + enable + + + + + + + diff --git a/eng/consumer-smoke/CoreConsumer/Program.cs b/eng/consumer-smoke/CoreConsumer/Program.cs new file mode 100644 index 0000000..625f87a --- /dev/null +++ b/eng/consumer-smoke/CoreConsumer/Program.cs @@ -0,0 +1,254 @@ +using Dapper; +using Dapper.FluentMap; +using Dapper.FluentMap.Configuration; +using Dapper.FluentMap.Mapping; +using Microsoft.Data.Sqlite; + +SQLitePCL.Batteries_V2.Init(); + +FluentMapper.Initialize(configuration => +{ + configuration.AddMap(); + configuration.AddMap(); + configuration.AddMap(); + configuration.AddMap(); + configuration.AddMap(); + configuration.AddProfile(); +}); + +using var connection = new SqliteConnection("Data Source=:memory:"); +connection.Open(); + +var explicitCustomer = connection.QuerySingle( + "SELECT 1 AS customer_id, 'Ada' AS full_name;"); +AssertEqual(1, explicitCustomer.Id, "explicit column id"); +AssertEqual("Ada", explicitCustomer.Name, "explicit column name"); + +var mappedCustomers = connection.QueryMapped( + "SELECT 2 AS customer_id, 'Grace' AS full_name UNION ALL SELECT 3 AS customer_id, 'Linus' AS full_name;") + .ToList(); +AssertEqual(2, mappedCustomers.Count, "QueryMapped count"); +AssertEqual(3, mappedCustomers[1].Id, "QueryMapped row"); + +var immutable = connection.QuerySingle( + "SELECT 4 AS immutable_id, 'Constructor' AS name;"); +AssertEqual(4, immutable.Id, "constructor id"); +AssertEqual("Constructor", immutable.Name, "constructor name"); + +var nested = connection.QueryMappedSingle( + "SELECT 5 AS customer_id, 'Sao Paulo' AS city;"); +AssertEqual(5, nested.Id, "nested id"); +AssertEqual("Sao Paulo", nested.Address?.City, "nested city"); + +var valueObject = connection.QueryMappedSingle( + "SELECT 6 AS customer_id, '12345678909' AS cpf;"); +AssertEqual(6, valueObject.Id, "value object id"); +AssertEqual("12345678909", valueObject.Cpf.Number, "value object cpf"); + +var profiled = connection.QueryMappedSingle( + "SELECT 7 AS legacy_id, 'Legacy Ltd.' AS legal_name;"); +AssertEqual(7, profiled.Id, "profile id"); +AssertEqual("Legacy Ltd.", profiled.Name, "profile name"); + +var converted = connection.QueryMappedSingle( + "SELECT 8 AS customer_id, 'A' AS status;"); +AssertEqual(AccountStatus.Active, converted.Status, "read converter"); + +if (!FluentMapper.EntityMaps.ContainsKey(typeof(CoreCustomer))) +{ + throw new InvalidOperationException("Legacy EntityMaps API did not expose the configured map."); +} + +if (!FluentMapper.GetEntityMaps().ContainsKey(typeof(CoreCustomer))) +{ + throw new InvalidOperationException("Read-only EntityMaps snapshot did not expose the configured map."); +} + +AssertIsolatedRuntime(connection); + +Console.WriteLine("core-consumer:ok"); + +static void AssertIsolatedRuntime(SqliteConnection connection) +{ + var firstRuntime = new FluentMapConfigurationBuilder() + .AddMap() + .Build() + .CreateRuntime(); + var secondRuntime = new FluentMapConfigurationBuilder() + .AddMap() + .Build() + .CreateRuntime(); + + var first = firstRuntime.QueryMappedSingle( + connection, + "SELECT 10 AS first_id;"); + var second = secondRuntime.QueryMappedSingle( + connection, + "SELECT 20 AS second_id;"); + + AssertEqual(10, first.Id, "first isolated runtime"); + AssertEqual(20, second.Id, "second isolated runtime"); +} + +static void AssertEqual(T expected, T actual, string label) +{ + if (!EqualityComparer.Default.Equals(expected, actual)) + { + throw new InvalidOperationException($"{label}: expected '{expected}', got '{actual}'."); + } +} + +public sealed class CoreCustomer +{ + public int Id { get; set; } + + public string Name { get; set; } = string.Empty; +} + +public sealed class CoreCustomerMap : EntityMap +{ + public CoreCustomerMap() + { + Map(customer => customer.Id).ToColumn("customer_id"); + Map(customer => customer.Name).ToColumn("full_name"); + } +} + +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("immutable_id"); + Map(customer => customer.Name).ToColumn("name"); + } +} + +public sealed class NestedCustomer +{ + public int Id { get; set; } + + public Address? Address { get; set; } +} + +public sealed class Address +{ + public string City { get; set; } = string.Empty; +} + +public sealed class NestedCustomerMap : EntityMap +{ + public NestedCustomerMap() + { + Map(customer => customer.Id).ToColumn("customer_id"); + Map(customer => customer.Address.City).ToColumn("city"); + } +} + +public sealed class ValueObjectCustomer +{ + public ValueObjectCustomer(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; } +} + +public sealed class ValueObjectCustomerMap : EntityMap +{ + public ValueObjectCustomerMap() + { + Map(customer => customer.Id).ToColumn("customer_id"); + Map(customer => customer.Cpf.Number).ToColumn("cpf"); + } +} + +public sealed class LegacyProfile : IMappingProfile +{ +} + +public sealed class LegacyCustomerMap : EntityMap, IProfileMap +{ + public LegacyCustomerMap() + { + Map(customer => customer.Id).ToColumn("legacy_id"); + Map(customer => customer.Name).ToColumn("legal_name"); + } +} + +public enum AccountStatus +{ + Unknown, + Active +} + +public sealed class ConvertedCustomer +{ + public int Id { get; set; } + + public AccountStatus Status { get; set; } +} + +public sealed class ConvertedCustomerMap : EntityMap +{ + public ConvertedCustomerMap() + { + Map(customer => customer.Id).ToColumn("customer_id"); + Map(customer => customer.Status).ToColumn("status").ConvertFromDatabaseUsing(); + } +} + +public sealed class StatusConverter : IReadPropertyConverter +{ + public AccountStatus ConvertFromDatabase(string value) + { + return value == "A" ? AccountStatus.Active : AccountStatus.Unknown; + } +} + +public sealed class IsolatedCustomer +{ + public int Id { get; set; } +} + +public sealed class FirstIsolatedCustomerMap : EntityMap +{ + public FirstIsolatedCustomerMap() + { + Map(customer => customer.Id).ToColumn("first_id"); + } +} + +public sealed class SecondIsolatedCustomerMap : EntityMap +{ + public SecondIsolatedCustomerMap() + { + Map(customer => customer.Id).ToColumn("second_id"); + } +} diff --git a/eng/consumer-smoke/DIConsumer/DIConsumer.csproj b/eng/consumer-smoke/DIConsumer/DIConsumer.csproj new file mode 100644 index 0000000..1ea7467 --- /dev/null +++ b/eng/consumer-smoke/DIConsumer/DIConsumer.csproj @@ -0,0 +1,16 @@ + + + Exe + net10.0 + false + enable + enable + + + + + + + + + diff --git a/eng/consumer-smoke/DIConsumer/Program.cs b/eng/consumer-smoke/DIConsumer/Program.cs new file mode 100644 index 0000000..f4286a2 --- /dev/null +++ b/eng/consumer-smoke/DIConsumer/Program.cs @@ -0,0 +1,125 @@ +using Dapper.FluentMap; +using Dapper.FluentMap.Configuration; +using Dapper.FluentMap.Mapping; +using Microsoft.Data.Sqlite; +using Microsoft.Extensions.DependencyInjection; + +SQLitePCL.Batteries_V2.Init(); + +using var connection = new SqliteConnection("Data Source=:memory:"); +connection.Open(); + +using (var explicitProvider = new ServiceCollection() + .AddFluentMap(builder => builder.AddMap()) + .BuildServiceProvider()) +{ + var configuration = explicitProvider.GetRequiredService(); + var runtime = explicitProvider.GetRequiredService(); + var customer = runtime.QueryMappedSingle( + connection, + "SELECT 11 AS customer_id;"); + + AssertEqual(1, configuration.EntityMaps.Count, "explicit DI map count"); + AssertEqual(11, customer.Id, "explicit DI query"); +} + +using (var generatedProvider = new ServiceCollection() + .AddFluentMap(builder => builder.Configure(configuration => configuration.AddGeneratedMappings())) + .BuildServiceProvider()) +{ + var runtime = generatedProvider.GetRequiredService(); + var customer = runtime.QueryMappedSingle( + connection, + "SELECT 12 AS generated_id;"); + + if (!runtime.Configuration.GeneratedMaterializers.Any(materializer => + materializer.EntityType == typeof(GeneratedDiCustomer))) + { + throw new InvalidOperationException("Generated DI registration did not capture a materializer."); + } + + AssertEqual(12, customer.Id, "generated DI query"); +} + +using (var firstProvider = new ServiceCollection() + .AddFluentMap(builder => builder.AddMap()) + .BuildServiceProvider()) +using (var secondProvider = new ServiceCollection() + .AddFluentMap(builder => builder.AddMap()) + .BuildServiceProvider()) +{ + var firstRuntime = firstProvider.GetRequiredService(); + var secondRuntime = secondProvider.GetRequiredService(); + + var first = firstRuntime.QueryMappedSingle( + connection, + "SELECT 21 AS first_id;"); + var second = secondRuntime.QueryMappedSingle( + connection, + "SELECT 22 AS second_id;"); + + AssertEqual(21, first.Id, "first isolated DI runtime"); + AssertEqual(22, second.Id, "second isolated DI runtime"); +} + +Console.WriteLine("di-consumer:ok"); + +static void AssertEqual(T expected, T actual, string label) +{ + if (!EqualityComparer.Default.Equals(expected, actual)) + { + throw new InvalidOperationException($"{label}: expected '{expected}', got '{actual}'."); + } +} + +public sealed class ExplicitDiCustomer +{ + public int Id { get; set; } +} + +public sealed class ExplicitDiCustomerMap : EntityMap +{ + public ExplicitDiCustomerMap() + { + Map(customer => customer.Id).ToColumn("customer_id"); + } +} + +public sealed class GeneratedDiCustomer +{ + public int Id { get; set; } +} + +public sealed class GeneratedDiCustomerMap : EntityMap +{ + public GeneratedDiCustomerMap() + { + Map(customer => customer.Id).ToColumn("generated_id"); + } +} + +public sealed class FirstIsolatedDiCustomer +{ + public int Id { get; set; } +} + +public sealed class FirstIsolatedDiCustomerMap : EntityMap +{ + public FirstIsolatedDiCustomerMap() + { + Map(customer => customer.Id).ToColumn("first_id"); + } +} + +public sealed class SecondIsolatedDiCustomer +{ + public int Id { get; set; } +} + +public sealed class SecondIsolatedDiCustomerMap : EntityMap +{ + public SecondIsolatedDiCustomerMap() + { + Map(customer => customer.Id).ToColumn("second_id"); + } +} diff --git a/eng/consumer-smoke/DommelConsumer/DommelConsumer.csproj b/eng/consumer-smoke/DommelConsumer/DommelConsumer.csproj new file mode 100644 index 0000000..1adf889 --- /dev/null +++ b/eng/consumer-smoke/DommelConsumer/DommelConsumer.csproj @@ -0,0 +1,14 @@ + + + Exe + net10.0 + false + enable + enable + + + + + + + diff --git a/eng/consumer-smoke/DommelConsumer/Program.cs b/eng/consumer-smoke/DommelConsumer/Program.cs new file mode 100644 index 0000000..7d5658b --- /dev/null +++ b/eng/consumer-smoke/DommelConsumer/Program.cs @@ -0,0 +1,211 @@ +using System.ComponentModel.DataAnnotations.Schema; +using Dapper; +using Dapper.FluentMap; +using Dapper.FluentMap.Dommel; +using Dapper.FluentMap.Dommel.Mapping; +using Dommel; +using Microsoft.Data.Sqlite; + +SQLitePCL.Batteries_V2.Init(); + +FluentMapper.Initialize(configuration => +{ + configuration.AddMap(new DommelSmokeEntityMap()); + configuration.AddMap(new AssignedKeyEntityMap()); + configuration.ForDommel(); +}); + +using var connection = new SqliteConnection("Data Source=:memory:"); +connection.Open(); +connection.Execute(""" +CREATE TABLE dommel_smoke_entities ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + normal TEXT NOT NULL, + ignored TEXT DEFAULT 'ignored-default', + read_only TEXT DEFAULT 'read-only-default', + database_default TEXT DEFAULT 'default-from-db', + computed TEXT GENERATED ALWAYS AS (normal || '-computed') STORED +); + +CREATE TABLE assigned_key_entities ( + code TEXT PRIMARY KEY, + name TEXT NOT NULL, + update_excluded TEXT +); +"""); + +var identityEntity = new DommelSmokeEntity +{ + Normal = "inserted", + Ignored = "ignored-write", + ReadOnly = "read-only-write", + DatabaseDefault = "default-write", + Computed = "computed-write" +}; + +var id = Convert.ToInt32(connection.Insert(identityEntity)); +if (id <= 0) +{ + throw new InvalidOperationException("Dommel identity insert did not return a generated key."); +} + +identityEntity.Id = id; +var inserted = SelectDommelSmokeRow(connection, id); +AssertEqual("inserted", inserted.Normal, "insert normal"); +AssertEqual("ignored-default", inserted.Ignored, "insert ignore default"); +AssertEqual("read-only-default", inserted.ReadOnly, "insert read-only default"); +AssertEqual("default-from-db", inserted.DatabaseDefault, "insert database default"); +AssertEqual("inserted-computed", inserted.Computed, "insert computed"); + +identityEntity.Normal = "updated"; +identityEntity.Ignored = "ignored-update"; +identityEntity.ReadOnly = "read-only-update"; +identityEntity.DatabaseDefault = "default-update"; +identityEntity.Computed = "computed-update"; + +if (!connection.Update(identityEntity)) +{ + throw new InvalidOperationException("Dommel update returned false."); +} + +var updated = SelectDommelSmokeRow(connection, id); +AssertEqual("updated", updated.Normal, "update normal"); +AssertEqual("ignored-default", updated.Ignored, "update ignore preserved"); +AssertEqual("read-only-default", updated.ReadOnly, "update read-only preserved"); +AssertEqual("default-update", updated.DatabaseDefault, "update database-default participates"); +AssertEqual("updated-computed", updated.Computed, "update computed"); + +var loaded = connection.Get(id); +AssertEqual(id, loaded!.Id, "Dommel Get id"); +AssertEqual("updated", loaded.Normal, "Dommel Get normal"); +AssertEqual(null, loaded.Ignored, "Dommel Get ignored"); +AssertEqual("read-only-default", loaded.ReadOnly, "Dommel Get read-only"); +AssertEqual("default-update", loaded.DatabaseDefault, "Dommel Get database default"); +AssertEqual("updated-computed", loaded.Computed, "Dommel Get computed"); + +var assigned = new AssignedKeyEntity +{ + Code = "A-001", + Name = "assigned-insert", + UpdateExcluded = "insert-only" +}; +connection.Insert(assigned); + +var assignedInserted = connection.QuerySingle( + "SELECT code AS Code, name AS Name, update_excluded AS UpdateExcluded FROM assigned_key_entities WHERE code = 'A-001';"); +AssertEqual("A-001", assignedInserted.Code, "assigned key insert code"); +AssertEqual("assigned-insert", assignedInserted.Name, "assigned key insert name"); +AssertEqual("insert-only", assignedInserted.UpdateExcluded, "assigned key insert update excluded"); + +assigned.Name = "assigned-update"; +assigned.UpdateExcluded = "should-not-update"; +if (!connection.Update(assigned)) +{ + throw new InvalidOperationException("Dommel non-identity key update returned false."); +} + +var assignedUpdated = connection.QuerySingle( + "SELECT code AS Code, name AS Name, update_excluded AS UpdateExcluded FROM assigned_key_entities WHERE code = 'A-001';"); +AssertEqual("A-001", assignedUpdated.Code, "assigned key update code"); +AssertEqual("assigned-update", assignedUpdated.Name, "assigned key update name"); +AssertEqual("insert-only", assignedUpdated.UpdateExcluded, "assigned key update excluded preserved"); + +Console.WriteLine("dommel-consumer:ok"); + +static DommelSmokeRow SelectDommelSmokeRow(SqliteConnection connection, int id) +{ + return connection.QuerySingle( + """ + SELECT + id AS Id, + normal AS Normal, + ignored AS Ignored, + read_only AS ReadOnly, + database_default AS DatabaseDefault, + computed AS Computed + FROM dommel_smoke_entities + WHERE id = @id; + """, + new { id }); +} + +static void AssertEqual(T expected, T actual, string label) +{ + if (!EqualityComparer.Default.Equals(expected, actual)) + { + throw new InvalidOperationException($"{label}: expected '{expected}', got '{actual}'."); + } +} + +public sealed class DommelSmokeEntity +{ + public int Id { get; set; } + + public string Normal { get; set; } = string.Empty; + + public string? Ignored { get; set; } + + public string? ReadOnly { get; set; } + + public string? DatabaseDefault { get; set; } + + public string? Computed { get; set; } +} + +public sealed class DommelSmokeEntityMap : DommelEntityMap +{ + public DommelSmokeEntityMap() + { + ToTable("dommel_smoke_entities"); + Map(entity => entity.Id).ToColumn("id").IsIdentity(); + Map(entity => entity.Normal).ToColumn("normal"); + Map(entity => entity.Ignored).ToColumn("ignored").Ignore(); + Map(entity => entity.ReadOnly).ToColumn("read_only").ReadOnly(); + Map(entity => entity.DatabaseDefault).ToColumn("database_default").DatabaseDefaultOnInsert(); + Map(entity => entity.Computed).ToColumn("computed").Computed(); + } +} + +public sealed class DommelSmokeRow +{ + public int Id { get; set; } + + public string Normal { get; set; } = string.Empty; + + public string? Ignored { get; set; } + + public string? ReadOnly { get; set; } + + public string? DatabaseDefault { get; set; } + + public string? Computed { get; set; } +} + +public sealed class AssignedKeyEntity +{ + public string Code { get; set; } = string.Empty; + + public string Name { get; set; } = string.Empty; + + public string? UpdateExcluded { get; set; } +} + +public sealed class AssignedKeyEntityMap : DommelEntityMap +{ + public AssignedKeyEntityMap() + { + ToTable("assigned_key_entities"); + Map(entity => entity.Code).ToColumn("code").IsKey().SetGeneratedOption(DatabaseGeneratedOption.None); + Map(entity => entity.Name).ToColumn("name"); + Map(entity => entity.UpdateExcluded).ToColumn("update_excluded").ExcludeFromUpdate(); + } +} + +public sealed class AssignedKeyRow +{ + public string Code { get; set; } = string.Empty; + + public string Name { get; set; } = string.Empty; + + public string? UpdateExcluded { get; set; } +} diff --git a/eng/consumer-smoke/GeneratorAnalyzerConsumer/GeneratorAnalyzerConsumer.csproj b/eng/consumer-smoke/GeneratorAnalyzerConsumer/GeneratorAnalyzerConsumer.csproj new file mode 100644 index 0000000..5030989 --- /dev/null +++ b/eng/consumer-smoke/GeneratorAnalyzerConsumer/GeneratorAnalyzerConsumer.csproj @@ -0,0 +1,18 @@ + + + Exe + net10.0 + false + enable + enable + true + $(BaseIntermediateOutputPath)generated + + + + + + + + + diff --git a/eng/consumer-smoke/GeneratorAnalyzerConsumer/Program.cs b/eng/consumer-smoke/GeneratorAnalyzerConsumer/Program.cs new file mode 100644 index 0000000..881c472 --- /dev/null +++ b/eng/consumer-smoke/GeneratorAnalyzerConsumer/Program.cs @@ -0,0 +1,132 @@ +using Dapper.FluentMap; +using Dapper.FluentMap.Mapping; +using Microsoft.Data.Sqlite; + +SQLitePCL.Batteries_V2.Init(); + +FluentMapper.Initialize(configuration => configuration.AddGeneratedMappings()); + +using var connection = new SqliteConnection("Data Source=:memory:"); +connection.Open(); + +var customer = connection.QueryMappedSingle( + "SELECT 42 AS customer_id, 'Ada' AS full_name;"); +AssertEqual(42, customer.Id, "generated customer id"); +AssertEqual("Ada", customer.Name, "generated customer name"); + +var nested = connection.QueryMappedSingle( + "SELECT 'Curitiba' AS city;"); +AssertEqual("Curitiba", nested.Address?.City, "generated nested city"); + +var valueObject = connection.QueryMappedSingle( + "SELECT '98765432100' AS cpf;"); +AssertEqual("98765432100", valueObject.Cpf.Number, "generated value object cpf"); + +var converted = connection.QueryMappedSingle( + "SELECT 'A' AS status;"); +AssertEqual(AccountStatus.Active, converted.Status, "generated read converter"); + +if (!FluentMapper.Configuration.GeneratedMaterializers.Any(materializer => + materializer.EntityType == typeof(GeneratedCustomer))) +{ + throw new InvalidOperationException("Generated materializer registration metadata was not captured."); +} + +Console.WriteLine("generator-analyzer-consumer:ok"); + +static void AssertEqual(T expected, T actual, string label) +{ + if (!EqualityComparer.Default.Equals(expected, actual)) + { + throw new InvalidOperationException($"{label}: expected '{expected}', got '{actual}'."); + } +} + +public sealed class GeneratedCustomer +{ + public int Id { get; set; } + + public string Name { get; set; } = string.Empty; +} + +public sealed class GeneratedCustomerMap : EntityMap +{ + public GeneratedCustomerMap() + { + Map(customer => customer.Id).ToColumn("customer_id"); + Map(customer => customer.Name).ToColumn("full_name"); + } +} + +public sealed class GeneratedNestedCustomer +{ + public GeneratedAddress? Address { get; set; } +} + +public sealed class GeneratedAddress +{ + public string City { get; set; } = string.Empty; +} + +public sealed class GeneratedNestedCustomerMap : EntityMap +{ + public GeneratedNestedCustomerMap() + { + Map(customer => customer.Address.City).ToColumn("city"); + } +} + +public sealed class GeneratedValueObjectCustomer +{ + public GeneratedValueObjectCustomer(GeneratedCpf cpf) + { + Cpf = cpf; + } + + public GeneratedCpf Cpf { get; } +} + +public sealed class GeneratedCpf +{ + public GeneratedCpf(string number) + { + Number = number; + } + + public string Number { get; } +} + +public sealed class GeneratedValueObjectCustomerMap : EntityMap +{ + public GeneratedValueObjectCustomerMap() + { + Map(customer => customer.Cpf.Number).ToColumn("cpf"); + } +} + +public enum AccountStatus +{ + Unknown, + Active +} + +public sealed class GeneratedConvertedCustomer +{ + public AccountStatus Status { get; set; } +} + +public sealed class GeneratedConvertedCustomerMap : EntityMap +{ + public GeneratedConvertedCustomerMap() + { + Map(customer => customer.Status).ToColumn("status").ConvertFromDatabaseUsing(); + } +} + +public sealed class GeneratedStatusConverter : IReadPropertyConverter +{ + public AccountStatus ConvertFromDatabase(string value) + { + return value == "A" ? AccountStatus.Active : AccountStatus.Unknown; + } +} diff --git a/eng/consumer-smoke/TrimExplicitConsumer/Program.cs b/eng/consumer-smoke/TrimExplicitConsumer/Program.cs new file mode 100644 index 0000000..b4e71e4 --- /dev/null +++ b/eng/consumer-smoke/TrimExplicitConsumer/Program.cs @@ -0,0 +1,36 @@ +using Dapper.FluentMap; +using Dapper.FluentMap.Mapping; +using Microsoft.Data.Sqlite; + +SQLitePCL.Batteries_V2.Init(); + +FluentMapper.Initialize(configuration => configuration.AddMap()); + +using var connection = new SqliteConnection("Data Source=:memory:"); +connection.Open(); + +var customer = connection.QueryMappedSingle( + "SELECT 31 AS customer_id, 'trim-explicit' AS customer_name;"); + +if (customer.Id != 31 || customer.Name != "trim-explicit") +{ + throw new InvalidOperationException("Trimmed explicit consumer did not materialize the expected row."); +} + +Console.WriteLine("trim-explicit-consumer:ok"); + +public sealed class TrimExplicitCustomer +{ + public int Id { get; set; } + + public string Name { get; set; } = string.Empty; +} + +public sealed class TrimExplicitCustomerMap : EntityMap +{ + public TrimExplicitCustomerMap() + { + Map(customer => customer.Id).ToColumn("customer_id"); + Map(customer => customer.Name).ToColumn("customer_name"); + } +} diff --git a/eng/consumer-smoke/TrimExplicitConsumer/TrimExplicitConsumer.csproj b/eng/consumer-smoke/TrimExplicitConsumer/TrimExplicitConsumer.csproj new file mode 100644 index 0000000..b4ab3d5 --- /dev/null +++ b/eng/consumer-smoke/TrimExplicitConsumer/TrimExplicitConsumer.csproj @@ -0,0 +1,14 @@ + + + Exe + net10.0 + false + enable + enable + + + + + + + diff --git a/eng/consumer-smoke/TrimGeneratedConsumer/Program.cs b/eng/consumer-smoke/TrimGeneratedConsumer/Program.cs new file mode 100644 index 0000000..e04e6b6 --- /dev/null +++ b/eng/consumer-smoke/TrimGeneratedConsumer/Program.cs @@ -0,0 +1,36 @@ +using Dapper.FluentMap; +using Dapper.FluentMap.Mapping; +using Microsoft.Data.Sqlite; + +SQLitePCL.Batteries_V2.Init(); + +FluentMapper.Initialize(configuration => configuration.AddGeneratedMappings()); + +using var connection = new SqliteConnection("Data Source=:memory:"); +connection.Open(); + +var customer = connection.QueryMappedSingle( + "SELECT 32 AS customer_id, 'trim-generated' AS customer_name;"); + +if (customer.Id != 32 || customer.Name != "trim-generated") +{ + throw new InvalidOperationException("Trimmed generated consumer did not materialize the expected row."); +} + +Console.WriteLine("trim-generated-consumer:ok"); + +public sealed class TrimGeneratedCustomer +{ + public int Id { get; set; } + + public string Name { get; set; } = string.Empty; +} + +public sealed class TrimGeneratedCustomerMap : EntityMap +{ + public TrimGeneratedCustomerMap() + { + Map(customer => customer.Id).ToColumn("customer_id"); + Map(customer => customer.Name).ToColumn("customer_name"); + } +} diff --git a/eng/consumer-smoke/TrimGeneratedConsumer/TrimGeneratedConsumer.csproj b/eng/consumer-smoke/TrimGeneratedConsumer/TrimGeneratedConsumer.csproj new file mode 100644 index 0000000..2453ed8 --- /dev/null +++ b/eng/consumer-smoke/TrimGeneratedConsumer/TrimGeneratedConsumer.csproj @@ -0,0 +1,15 @@ + + + Exe + net10.0 + false + enable + enable + + + + + + + + diff --git a/eng/consumer-smoke/run-consumer-smoke.ps1 b/eng/consumer-smoke/run-consumer-smoke.ps1 new file mode 100644 index 0000000..1b722d4 --- /dev/null +++ b/eng/consumer-smoke/run-consumer-smoke.ps1 @@ -0,0 +1,570 @@ +[CmdletBinding()] +param( + [string]$RepositoryRoot, + [string]$RemoteArtifactDirectory, + [string]$PackageDirectory, + [switch]$SkipTrimPublish +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +$packageVersion = '3.0.0-rc.1' +$expectedPackageIds = @( + 'Dapper.FluentMap', + 'Dapper.FluentMap.Dommel', + 'Dapper.FluentMap.DependencyInjection', + 'Dapper.FluentMap.Analyzers', + 'Dapper.FluentMap.Generators' +) + +function Fail { + param([string]$Message) + throw "Consumer smoke failed: $Message" +} + +function Get-RepoRoot { + if (-not [string]::IsNullOrWhiteSpace($RepositoryRoot)) { + return (Resolve-Path -LiteralPath $RepositoryRoot).Path + } + + return (Resolve-Path -LiteralPath (Join-Path $PSScriptRoot '../..')).Path +} + +function Invoke-External { + param( + [string]$FilePath, + [string[]]$Arguments, + [string]$LogPath, + [switch]$AllowFailure + ) + + Write-Host "> $FilePath $($Arguments -join ' ')" + $output = & $FilePath @Arguments 2>&1 + $exitCode = $LASTEXITCODE + + if (-not [string]::IsNullOrWhiteSpace($LogPath)) { + $logDirectory = Split-Path -Parent $LogPath + New-Item -ItemType Directory -Force -Path $logDirectory | Out-Null + [System.IO.File]::WriteAllText( + $LogPath, + ($output -join [Environment]::NewLine) + [Environment]::NewLine, + [System.Text.UTF8Encoding]::new($false)) + } + + if ($exitCode -ne 0 -and -not $AllowFailure) { + $tail = ($output | Select-Object -Last 60) -join [Environment]::NewLine + Fail "Command failed with exit code $exitCode. Tail:$([Environment]::NewLine)$tail" + } + + return [pscustomobject]@{ + ExitCode = $exitCode + Output = @($output) + } +} + +function Get-CurrentRid { + if ([System.Runtime.InteropServices.RuntimeInformation]::ProcessArchitecture -eq [System.Runtime.InteropServices.Architecture]::Arm64) { + $architecture = 'arm64' + } + else { + $architecture = 'x64' + } + + if ([System.Runtime.InteropServices.RuntimeInformation]::IsOSPlatform([System.Runtime.InteropServices.OSPlatform]::Windows)) { + return "win-$architecture" + } + + if ([System.Runtime.InteropServices.RuntimeInformation]::IsOSPlatform([System.Runtime.InteropServices.OSPlatform]::Linux)) { + return "linux-$architecture" + } + + if ([System.Runtime.InteropServices.RuntimeInformation]::IsOSPlatform([System.Runtime.InteropServices.OSPlatform]::OSX)) { + return "osx-$architecture" + } + + Fail 'Unsupported OS for trimming consumer smoke.' +} + +function Ensure-RemoteArtifacts { + param( + [string]$RepoRoot, + [pscustomobject]$Manifest, + [string]$RemoteRoot + ) + + $missing = @( + foreach ($artifact in $Manifest.artifacts) { + if (@(Get-ChildItem -Path $RemoteRoot -Recurse -File -Filter $artifact.name -ErrorAction SilentlyContinue).Count -eq 0) { + $artifact.name + } + } + ) + + if ($missing.Count -eq 0) { + return + } + + $gh = Get-Command gh -ErrorAction SilentlyContinue + if ($null -eq $gh) { + Fail "Missing remote artifacts and GitHub CLI is not available. Missing: $($missing -join ', ')." + } + + New-Item -ItemType Directory -Force -Path $RemoteRoot | Out-Null + Invoke-External ` + -FilePath $gh.Source ` + -Arguments @( + 'run', + 'download', + [string]$Manifest.workflow.workflowRunId, + '--repo', + [string]$Manifest.repository, + '--name', + [string]$Manifest.workflow.artifactName, + '--dir', + $RemoteRoot) ` + -LogPath (Join-Path $RepoRoot '.tmp/consumer-smoke/logs/gh-run-download.log') | Out-Null +} + +function Get-ValidatedArtifacts { + param( + [pscustomobject]$Manifest, + [string]$RemoteRoot + ) + + $files = @{} + foreach ($artifact in $Manifest.artifacts) { + $matches = @(Get-ChildItem -Path $RemoteRoot -Recurse -File -Filter $artifact.name) + if ($matches.Count -ne 1) { + Fail "Expected exactly one downloaded artifact named '$($artifact.name)', found $($matches.Count)." + } + + $hash = (Get-FileHash -Algorithm SHA256 -LiteralPath $matches[0].FullName).Hash.ToLowerInvariant() + if ($hash -ne $artifact.sha256) { + Fail "SHA-256 mismatch for '$($artifact.name)'. Expected $($artifact.sha256), got $hash." + } + + $files[$artifact.name] = $matches[0] + } + + return $files +} + +function New-CleanDirectory { + param( + [string]$Path, + [string]$ExpectedParent + ) + + $parent = (Resolve-Path -LiteralPath $ExpectedParent).Path + if (Test-Path -LiteralPath $Path) { + $resolved = (Resolve-Path -LiteralPath $Path).Path + if (-not $resolved.StartsWith($parent, [System.StringComparison]::OrdinalIgnoreCase)) { + Fail "Refusing to clean '$resolved' because it is outside '$parent'." + } + + Remove-Item -LiteralPath $resolved -Recurse -Force + } + + New-Item -ItemType Directory -Force -Path $Path | Out-Null +} + +function Write-NuGetConfig { + param( + [string]$Path, + [string]$FeedDirectory + ) + + $feed = [System.Security.SecurityElement]::Escape($FeedDirectory) + $nugetConfig = @" + + + + + + + + + + + + + + + + + + + + + + + +"@ + + [System.IO.File]::WriteAllText($Path, $nugetConfig, [System.Text.UTF8Encoding]::new($false)) +} + +function Assert-NoSourceReferences { + param([string]$ProjectPath) + + [xml]$projectXml = Get-Content -LiteralPath $ProjectPath + $projectReferences = @($projectXml.SelectNodes('//*[local-name()="ProjectReference"]')) + if ($projectReferences.Count -gt 0) { + Fail "$ProjectPath contains ProjectReference, which is forbidden for package consumer smoke." + } + + $text = Get-Content -LiteralPath $ProjectPath -Raw + if ($text -match 'src[\\/]+Dapper\.FluentMap' -or $text -match ' Date: Wed, 29 Jul 2026 15:29:40 -0300 Subject: [PATCH 48/49] chore(release): finalize 3.0.0-rc.1 candidate --- .sdd/release-3.0.0-rc.1/05-rc-gate-report.md | 64 ++++++++++++++++++++ .sdd/release-3.0.0-rc.1/STATUS.md | 30 ++++++--- CHANGELOG.md | 37 ++++++++++- 3 files changed, 121 insertions(+), 10 deletions(-) create mode 100644 .sdd/release-3.0.0-rc.1/05-rc-gate-report.md diff --git a/.sdd/release-3.0.0-rc.1/05-rc-gate-report.md b/.sdd/release-3.0.0-rc.1/05-rc-gate-report.md new file mode 100644 index 0000000..0faeace --- /dev/null +++ b/.sdd/release-3.0.0-rc.1/05-rc-gate-report.md @@ -0,0 +1,64 @@ +# RC Gate Report - 3.0.0-rc.1 + +## Scope + +Prompt RC.5 consolidates the final local release-candidate gate for +`3.0.0-rc.1`. No product code blocker was found and no feature work was added. + +Candidate package artifacts must be regenerated after the RC.5 candidate commit +so the package repository commit can match the final SHA. The pre-commit package +gate below validated the package shape and consumption path before creating that +single candidate commit. + +## Gate Inventory + +| Gate | Evidence | Status | Blocker | +| ---- | -------- | ------ | ------- | +| branch | `git status --short --branch`: `release/3.0.0-rc.1...origin/release/3.0.0-rc.1 [ahead 2]` before RC.5 edits | Passed | No | +| versionamento | `Directory.Build.props`, `Directory.Build.targets`, release workflow and artifact validator lock `3.0.0-rc.1`; pack gate generated only `3.0.0-rc.1` packages | Passed | No | +| restore | `dotnet restore ./Dapper.FluentMap.sln` | Passed | No | +| build | `dotnet build ./Dapper.FluentMap.sln --configuration Release --no-restore -p:Version=3.0.0-rc.1` | Passed, 0 warnings, 0 errors | No | +| tests | `dotnet test ./Dapper.FluentMap.sln --configuration Release --no-build` | Passed: core, Dommel, analyzer, generator, generated registration, DI and provider test projects | No | +| SQLite | `dotnet test ./test/Dapper.FluentMap.ProviderCompatibility.Tests/Dapper.FluentMap.ProviderCompatibility.Tests.csproj --configuration Release --no-build` | Passed: 7 SQLite tests; SQL Server/PostgreSQL skipped by missing connection strings | No | +| package validation | `dotnet pack ./Dapper.FluentMap.sln --configuration Release --no-build --output -p:Version=3.0.0-rc.1`; `eng/validate-release-artifacts.ps1` | Passed: 5 `.nupkg`, 3 `.snupkg`, nuspecs, layouts, dependency ranges and metadata valid | No | +| artifacts | Pre-commit RC.5 artifact directory contained the expected 8 artifacts plus generated `artifact-manifest.json` | Passed | No | +| remote workflow | `.github/workflows/release.yml` read; RC.3 run `30476842589` passed validate and provenance jobs on `release/3.0.0-rc.1`; workflow now includes consumer smoke | Ready with known limitation | No | +| SourceLink | RC.3 validated SourceLink for core, DI, Dommel, analyzer and generator PDBs against commit `44f690195f9a06703e04c051411047b993644186` | Ready for final remote SHA validation after push | No | +| provenance | RC.3 validated GitHub artifact attestations for 8 package artifacts with SLSA provenance v1 | Ready for final remote SHA validation after push | No | +| consumer core | `eng/consumer-smoke/run-consumer-smoke.ps1 -PackageDirectory ` | Passed | No | +| analyzer | Consumer smoke restored analyzer package as compiler analyzer and diagnostic consumer emitted `DFM001` | Passed | No | +| generator | Consumer smoke restored generator package, generated `AddGeneratedMappings()` and materialized generated scenarios | Passed | No | +| DI | Consumer smoke validated `AddFluentMap`, isolated runtimes and generated DI registration | Passed | No | +| Dommel | Consumer smoke validated SQLite insert/update/default/computed/read-only/ignored metadata scenarios | Passed | No | +| trimming | Consumer smoke published and executed `TrimExplicitConsumer` and `TrimGeneratedConsumer` on `win-x64` with no trimming warnings | Passed | No | +| vulnerability audit | `dotnet list ./Dapper.FluentMap.sln package --vulnerable --include-transitive` | Passed: no vulnerable packages reported | No | +| documentation | `CHANGELOG.md`, README/adoption docs from prior gates and SDD release docs reviewed | Passed after RC.5 release-note finalization | No | +| changelog | `CHANGELOG.md` section `3.0.0-rc.1 - Unreleased` finalized for RC scope, risks, provider status, AOT/trimming limits, migration and RC status | Passed | No | + +## Issue Classification + +| Classification | Issue | Disposition | +| -------------- | ----- | ----------- | +| Critical | None found | No RC change required | +| High | None found | No RC change required | +| Medium | `workflow_dispatch` for `release.yml` is unavailable until the workflow exists on the default branch; RC.3 qualification used the `push` trigger successfully | Not an RC package blocker; keep operational limitation documented | +| Low | GitHub CLI attestation download is public preview | Documented; no product or package impact | +| Stable-only | SQL Server and PostgreSQL harnesses are conditional and not certified in CI | Do not claim provider certification beyond SQLite for RC.1 | +| Stable-only | Package signing and formal SBOM are undecided | Keep out of RC.1 publication gate | +| Stable-only | Fork-owned API/binary baseline and RC adoption feedback are still needed for stable | Stable release blocker, not RC blocker | +| Post-3.0 | Full Native AOT support and write-converter execution in Dapper/Dommel write paths | Backlog; no RC.1 feature change | + +## Exit Criteria + +| Criterion | Result | +| --------- | ------ | +| Critical blockers | 0 | +| High RC blockers | 0 | +| Remote qualification infrastructure | Ready | +| Consumer smoke | Passed | +| Package version | `3.0.0-rc.1` | + +## RC.5 Result + +Candidate state: Ready for publication qualification, pending regeneration of +candidate packages from the final RC.5 commit SHA. diff --git a/.sdd/release-3.0.0-rc.1/STATUS.md b/.sdd/release-3.0.0-rc.1/STATUS.md index e1e0c0d..f61fd15 100644 --- a/.sdd/release-3.0.0-rc.1/STATUS.md +++ b/.sdd/release-3.0.0-rc.1/STATUS.md @@ -2,10 +2,14 @@ ## Estado -RC.4 consumer smoke concluido. Os packages `3.0.0-rc.1` registrados no RC.3 -foram consumidos fora da solution do FluentMap a partir de feed NuGet local -temporario formado exclusivamente pelos artifacts remotos validados, com -NuGet.org apenas para dependencias externas. +RC.5 gate local concluido sem blockers Critical ou High de RC. Os packages +`3.0.0-rc.1` foram validados por restore, build, tests, provider SQLite, +artifact validation, consumer smoke, trimming smoke, vulnerability audit e +benchmark guardrail representativo. + +Candidate state: Ready for publication qualification +Candidate commit: registrado apos a criacao do commit unico RC.5, sem segundo +commit de evidencia, para evitar ciclo autorreferencial de hash. ## Commit candidato atual @@ -76,6 +80,10 @@ existente na default branch. de `Dapper 2.1.79` e `Dommel 3.5.3`, e nenhum package `2.0.0`. - Workflow `Release` passou a executar consumer smoke como gate apos validacao de artifacts. +- Prompt RC.5 executou inventario de gates, classificacao de problemas e + validacao local final antes do commit candidato. +- `.sdd/release-3.0.0-rc.1/05-rc-gate-report.md` criado. +- `CHANGELOG.md` finalizado para a secao `3.0.0-rc.1 - Unreleased`. ## Em andamento @@ -83,13 +91,17 @@ existente na default branch. ## Proximos passos -1. Revisar o commit de evidencia do RC.4. -2. Prosseguir para RC.5 com decisao de promocao/publicacao sem publicar - automaticamente. +1. Revisar o commit candidato RC.5. +2. Regenerar/qualificar artifacts remotos apos push autorizado da branch de + release. +3. Antes do Prompt RC.6, criar autorizacao manual + `.sdd/release-3.0.0-rc.1/PUBLISH-AUTHORIZATION.md`. ## RC blockers -- Remote qualification: Passed with limitations. +- Critical blockers: 0. +- High RC blockers: 0. +- Remote qualification infrastructure: Ready with documented limitation. - `workflow_dispatch` de `release.yml` continua indisponivel ate o workflow existir na default branch ou outro caminho de promocao ser definido. A qualificacao RC.3 foi executada por evento `push` na branch de release. @@ -133,4 +145,4 @@ existente na default branch. ## Ultimo prompt executado -Ultimo prompt executado: RC.4 +Ultimo prompt executado: RC.5 diff --git a/CHANGELOG.md b/CHANGELOG.md index 87c1cb5..3bd45fa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,9 @@ The historical archived package history is not reconstructed here. This changelo ## [3.0.0-rc.1] - Unreleased +Release candidate status: ready for publication qualification. The date remains +`Unreleased` until the packages are actually published. + ### Added - Generated materialization support for eligible explicit mappings, including runtime fallback for unsupported shapes. @@ -24,11 +27,42 @@ The historical archived package history is not reconstructed here. This changelo - Advanced FluentMap-controlled query materialization, including `QueryMultipleMapped`, `ReadMapped*` and sync/async streaming helpers. - Property converter metadata and read conversion in FluentMap-controlled materialization. - Isolated immutable configuration, `FluentMapRuntime` and optional dependency injection integration. +- Roslyn analyzer and source generator packages for map-expression diagnostics and generated registration/materialization. +- Consumer smoke coverage for core, analyzer, generator, Dependency Injection, Dommel and trimmed package consumers. ### Changed - Release versioning is hardened so default local pack produces `3.0.0-dev`, not historical `2.0.0` or accidental stable `3.0.0`. - Release workflow uses an explicit validated package version for RC artifacts. +- Public mapping configuration can now be isolated through immutable configuration/runtime APIs while the legacy global facade remains available. +- Dommel integration honors the new persistence metadata for supported write scenarios. + +### Breaking and Risky Changes + +- The fork line uses package version `3.0.0-rc.1`; consumers should treat it as a major-version release candidate rather than a drop-in stable upgrade. +- New generated materialization paths and query helpers are opt-in and have runtime fallback, but they expand the public surface that must be validated before stable. +- Legacy `FluentMapper` and `SqlMapper.SetTypeMap` behavior remains global and process-wide; isolated runtime APIs do not remove that existing global state for consumers that still use it. +- Write converters are not executed by the current Dapper/Dommel write path; converter metadata is available, but write conversion remains outside the RC.1 behavior claim. + +### Provider Status + +- SQLite is the certified RC.1 provider path and is covered by local, CI and consumer smoke validation. +- SQL Server and PostgreSQL compatibility harnesses exist but require connection strings and are not certified by the default RC.1 gate. + +### AOT and Trimming + +- Trimmed package consumers for explicit and generated mapping scenarios publish and run successfully in the RC gate with no trimming warnings observed. +- Full Native AOT support is not claimed for RC.1. +- Assembly scanning and reflection-heavy configuration remain risky under trimming/AOT unless the consumer preserves required members. + +### Migration Guide + +- Pin all FluentMap packages to the exact same prerelease version: `3.0.0-rc.1`. +- Keep existing global `FluentMapper.Initialize` usage when source compatibility is the priority. +- Prefer `FluentMapConfigurationBuilder` and `FluentMapRuntime` for new isolated configurations or DI-based composition. +- Add `Dapper.FluentMap.DependencyInjection` only when using `Microsoft.Extensions.DependencyInjection`. +- Add `Dapper.FluentMap.Analyzers` and `Dapper.FluentMap.Generators` as analyzer/compiler packages; they should not be consumed as runtime libraries. +- For Dommel, keep using `Dapper.FluentMap.Dommel` and verify key/default/computed/read-only metadata against real write scenarios before promoting from RC. ### Known Limitations @@ -36,7 +70,8 @@ The historical archived package history is not reconstructed here. This changelo - Write converters are metadata-only in the current Dapper/Dommel write path. - SQL Server and PostgreSQL have conditional harnesses but are not certified in CI. - Full Native AOT support is not claimed. -- Stable release remains blocked on fork-owned API/binary baseline, SourceLink validation on remote SHA, package signing/SBOM decision and RC feedback. +- SourceLink and provenance are ready in the release workflow and were validated for the previous remote RC qualification SHA; final candidate artifacts require the final commit to be pushed before remote SourceLink/provenance can be requalified. +- Stable release remains blocked on fork-owned API/binary baseline, package signing/SBOM decision and RC feedback. ## Fork Release Candidate Line From 2315d89b3bf171c5a449b9c8f1a4eb764d3a1020 Mon Sep 17 00:00:00 2001 From: Rodrigo de Oliveira Date: Wed, 29 Jul 2026 15:37:59 -0300 Subject: [PATCH 49/49] docs(release): record 3.0.0-rc.1 publication block --- .../06-publication-report.md | 106 ++++++++++++++++++ .sdd/release-3.0.0-rc.1/STATUS.md | 10 +- 2 files changed, 115 insertions(+), 1 deletion(-) create mode 100644 .sdd/release-3.0.0-rc.1/06-publication-report.md diff --git a/.sdd/release-3.0.0-rc.1/06-publication-report.md b/.sdd/release-3.0.0-rc.1/06-publication-report.md new file mode 100644 index 0000000..4ade793 --- /dev/null +++ b/.sdd/release-3.0.0-rc.1/06-publication-report.md @@ -0,0 +1,106 @@ +# Publication Report + +## Version + +Requested version: `3.0.0-rc.1`. + +Publication status: Blocked before publication. + +## Candidate commit + +Local HEAD at RC.6 start: `b4e0323bee471e0758e2fcc73c69d414e719dcca`. + +The required publication authorization file +`.sdd/release-3.0.0-rc.1/PUBLISH-AUTHORIZATION.md` was not present, so the +authorized candidate commit could not be validated. + +Existing SDD evidence also contains remote artifact metadata for +`44f690195f9a06703e04c051411047b993644186`, while the local release branch +HEAD is `b4e0323bee471e0758e2fcc73c69d414e719dcca`. This requires explicit +authorization before any publication action. + +## Tag + +Not created. + +Required tag: `v3.0.0-rc.1`. + +## Workflow run + +No final RC.6 workflow was started. + +Existing documented remote qualification run: `30476842589`, associated with +commit `44f690195f9a06703e04c051411047b993644186`. + +## NuGet packages + +No packages were published. + +Expected package IDs: + +- `Dapper.FluentMap` +- `Dapper.FluentMap.Analyzers` +- `Dapper.FluentMap.Generators` +- `Dapper.FluentMap.DependencyInjection` +- `Dapper.FluentMap.Dommel` + +## Package hashes + +No final RC.6 package hashes were produced or published. + +The versioned `.sdd/release-3.0.0-rc.1/artifacts.json` still records hashes for +the earlier remote qualification commit +`44f690195f9a06703e04c051411047b993644186`; these were not reused for +publication. + +## SourceLink + +Not revalidated for RC.6 because the mandatory authorization gate failed before +remote final qualification. + +## Provenance + +Not revalidated for RC.6 because the mandatory authorization gate failed before +remote final qualification. + +## GitHub Release + +Not created. + +## Verification + +Performed before stopping: + +- Confirmed required authorization file is missing: + `.sdd/release-3.0.0-rc.1/PUBLISH-AUTHORIZATION.md`. +- Confirmed local branch: `release/3.0.0-rc.1`. +- Confirmed local HEAD: `b4e0323bee471e0758e2fcc73c69d414e719dcca`. +- Confirmed working tree was clean before this documentation update. +- Confirmed local branch was ahead of `origin/release/3.0.0-rc.1`. +- Confirmed existing artifact manifest references commit + `44f690195f9a06703e04c051411047b993644186`. + +Not executed because publication authorization was absent: + +- final restore/build/test gate; +- consumer smoke; +- pack from final candidate commit; +- artifact validation; +- vulnerability audit; +- push; +- final remote workflow; +- NuGet publication; +- tag creation; +- GitHub pre-release creation. + +## Incidents + +Critical authorization gate failed: missing +`.sdd/release-3.0.0-rc.1/PUBLISH-AUTHORIZATION.md`. + +Additional operational note: querying `origin` through the configured fetch URL +failed with SSH public-key authentication, so no remote mutation was attempted. + +## Result + +Blocked. `3.0.0-rc.1` was not published. diff --git a/.sdd/release-3.0.0-rc.1/STATUS.md b/.sdd/release-3.0.0-rc.1/STATUS.md index f61fd15..482fa43 100644 --- a/.sdd/release-3.0.0-rc.1/STATUS.md +++ b/.sdd/release-3.0.0-rc.1/STATUS.md @@ -2,6 +2,14 @@ ## Estado +Publication: Blocked +Version: 3.0.0-rc.1 + +Prompt RC.6 bloqueado antes de qualquer acao irreversivel porque o arquivo +obrigatorio `.sdd/release-3.0.0-rc.1/PUBLISH-AUTHORIZATION.md` nao existe. +Nenhum package foi publicado, nenhuma tag foi criada e nenhuma GitHub Release +foi criada. + RC.5 gate local concluido sem blockers Critical ou High de RC. Os packages `3.0.0-rc.1` foram validados por restore, build, tests, provider SQLite, artifact validation, consumer smoke, trimming smoke, vulnerability audit e @@ -145,4 +153,4 @@ existente na default branch. ## Ultimo prompt executado -Ultimo prompt executado: RC.5 +Ultimo prompt executado: RC.6