Skip to content

Latest commit

 

History

3 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Demo Padrão EAV — Tipos dinâmicos sem DDL em tempo de execução

⚠️ Projeto didático. Este repositório é uma prova de conceito, criada para demonstrar o padrão Entity-Attribute-Value aplicado com rigor relacional: um usuário define, em tempo de execução, quais propriedades cada tipo de registro possui — e o banco continua garantindo integridade, tipagem e capacidade de consulta, sem gerar uma única linha de DDL. Ele não é uma referência pronta para produção — várias simplificações foram feitas de propósito para manter o foco no aprendizado (ver Limitações).

O domínio é a agenda de um agente de crédito. Cada Compromisso pertence a um TipoCompromisso, e cada tipo tem um conjunto próprio de propriedades — Visita Técnica pede valor solicitado e data da visita, Renegociação pede saldo devedor e quantidade de parcelas, Análise Documental pede protocolo e prazo de retorno. O usuário cria tipos e propriedades pela API, e a aplicação passa a aceitar, validar e devolver esses campos imediatamente.

A crítica mais comum e mais justa ao EAV é que a coerência dos dados passa a depender inteiramente da aplicação. Este projeto existe para mostrar que não precisa ser assim: uma chave estrangeira composta faz o próprio banco recusar gravar, em um compromisso do tipo A, o valor de uma propriedade do tipo B.


Visão geral do fluxo

                        METAMODELO (o usuário define)
    ┌──────────────────────────────────────────────────────────┐
    │  POST /api/tipos-de-compromisso                          │
    │  POST /api/tipos-de-compromisso/{id}/propriedades        │
    └───────────────────────────┬──────────────────────────────┘
                                ▼
              TipoCompromisso ──1:N──> PropriedadeTipo
                    │                        │
                    │                        │  Chave · Rotulo · TipoDado
                    │                        │  Obrigatoria · Ordem · Ativa
                    ▼                        ▼
                        DADOS (o usuário preenche)
    ┌──────────────────────────────────────────────────────────┐
    │  POST /api/compromissos    { chave, valor }              │
    └───────────────────────────┬──────────────────────────────┘
                                ▼
             Compromisso ──1:N──> CompromissoValor <──1:N── PropriedadeTipo
                                        │
                        ValorTexto · ValorNumero · ValorData · ValorBool
                        (exatamente uma preenchida — CHECK do banco)

    As duas FKs de CompromissoValor compartilham TipoCompromissoId:
      FK (CompromissoId, TipoCompromissoId) → Compromisso   (Id, TipoCompromissoId)
      FK (PropriedadeId, TipoCompromissoId) → PropriedadeTipo (Id, TipoCompromissoId)
    Não existe valor de TipoCompromissoId que faça uma linha incoerente passar.
  1. O usuário cria um tipo e suas propriedades — a Chave é gerada pelo sistema a partir do rótulo, normalizada e imutável.
  2. A tela consulta o metamodelo (GET /api/tipos-de-compromisso/{id}) e monta o formulário. Nenhuma coluna física é conhecida em tempo de compilação.
  3. Ao gravar um compromisso, a aplicação lê o TipoDado declarado e decide em qual coluna tipada o valor entra. É a única responsabilidade que o banco não consegue assumir sozinho — porque o TipoDado mora em outra tabela.
  4. Ao ler, a varredura parte de PropriedadeTipo (não de CompromissoValor), então propriedades ainda não preenchidas aparecem vazias em vez de sumir do formulário.

A modelagem completa que embasa o desenho está em Documentação complementar.


Padrões e mecanismos aplicados

Padrão / mecanismo Onde O que resolve
Metamodelo separado dos dados TipoCompromisso + PropriedadeTipo vs. Compromisso + CompromissoValor A definição é dado, não schema. Criar um tipo novo é um INSERT, nunca um CREATE TABLE.
FK composta (coerência de tipo) CompromissoValor carrega TipoCompromissoId de forma redundante O banco recusa gravar, em um compromisso do tipo A, o valor de uma propriedade do tipo B. É a resposta à crítica clássica ao EAV.
Colunas de valor tipadas ValorTexto · ValorNumero · ValorData · ValorBool '92500' > '180000' é verdadeiro numa coluna VARCHAR única. Com colunas tipadas, filtro e ordenação funcionam de verdade.
CHECK de valor exclusivo CK_Valor_Exclusivo Exatamente uma coluna de valor preenchida por linha — nem duas, nem nenhuma.
PK composta PK (CompromissoId, PropriedadeId) Uma propriedade não pode ser preenchida duas vezes no mesmo compromisso.
Ausência de linha = não preenchido DadosIniciaisSeed e ServicoDeCompromissos Nunca se grava uma linha com todas as colunas nulas. É isso que mantém o relatório de pendências correto.
Chave estável e imutável GeradorDeChaveDePropriedade Rotulo é livre e o usuário renomeia à vontade; Chave é gerada, normalizada e nunca muda. Ela também é a fronteira contra injeção em pivots dinâmicos.
Soft delete no metamodelo Ativo / Ativa Excluir propriedade é Ativa = 0: some do formulário, mas continua visível nos compromissos que já têm valor gravado. Histórico preservado.
Troca de tipo de dado bloqueada ServicoDePropriedadesDoTipo.AlterarTipoDeDadoAsync Se já existe valor gravado, a troca é recusada. A saída correta é criar propriedade nova e inativar a anterior.
Obrigatoriedade em runtime PropriedadesObrigatoriasNaoPreenchidas Obrigatório não é NOT NULL: a regra é ligada depois que já existem registros, então quem valida é a aplicação — e os antigos continuam válidos.
Índices filtrados por tipo de valor IX_Valor_Numero · IX_Valor_Data É o que viabiliza filtro e ordenação reais sobre propriedades dinâmicas — o ganho de ter colunas tipadas em vez de uma coluna textual.
Validação agregada ExcecaoDeRequisicaoInvalida Todos os problemas do payload voltam de uma vez em inconsistencias, não um por requisição.
Mapeamento de chaves alternativas no EF ContextoDeBancoDeDados HasAlternateKey + HasPrincipalKey com par de colunas: a FK composta do modelo existe também no ORM.
Esquema criado fora da aplicação docker/inicializacao/01-criar-esquema.sql Sem migrations, de propósito: o banco é a fonte da verdade e o DDL fica legível ao lado do material didático.
Seed idempotente no start Infraestrutura/Seeds/DadosIniciaisSeed Carga de demonstração em transação única, que não duplica em reinícios.

Projetos / containers

# Projeto Tipo Responsabilidade
1 demo-eav.api ASP.NET Core 10 (REST) Projeto único e monolítico, de propósito. Dominio, Infraestrutura (EF Core + seeds), Aplicacao (requisições, respostas, serviços) e Controllers.
SQL Server 2022 Infra (container) Banco DemonstracaoPadraoEav, com as quatro tabelas do modelo EAV.
mssql-tools Infra (container efêmero) Espera o banco subir, cria o esquema via sqlcmd e encerra.
DBGate Infra (container) Cliente web para inspecionar os dados, já com a conexão registrada.

O modelo

Quatro tabelas, divididas em duas camadas:

Camada Tabela Papel
Metamodelo TipoCompromisso O tipo que o usuário cadastra (Visita Técnica, Renegociação…).
Metamodelo PropriedadeTipo As propriedades de cada tipo: chave, rótulo, tipo de dado, obrigatoriedade, ordem.
Dados Compromisso A instância, com as propriedades fixas DataHora e Descricao.
Dados CompromissoValor A tabela associativa que materializa o E-A-V.
Entity    → CompromissoId
Attribute → PropriedadeId
Value     → ValorTexto / ValorNumero / ValorData / ValorBool

Tipos de dado suportados: TEXTO, INTEIRO, DECIMAL, DATA, BOOL — expostos na API como Texto, Inteiro, Decimal, Data, Booleano.

O detalhamento completo — cardinalidades, restrições de integridade, regras de evolução do metamodelo e índices sugeridos — está em anexos/modelagem.md.


Endpoints

Tipos de compromisso

Verbo Rota O que faz
GET /api/tipos-de-compromisso?incluirInativos= Lista com contagem de propriedades e de compromissos vinculados
GET /api/tipos-de-compromisso/{id}?incluirPropriedadesInativas= O tipo com suas propriedades — é a consulta que monta o formulário
POST /api/tipos-de-compromisso Cria o tipo
PUT /api/tipos-de-compromisso/{id}/nome Renomeia
POST /api/tipos-de-compromisso/{id}/inativacao · /reativacao Soft delete e volta
DELETE /api/tipos-de-compromisso/{id} Exclusão física — bloqueada se houver compromissos vinculados

Propriedades do tipo

Verbo Rota O que faz
GET /api/tipos-de-compromisso/{tipo}/propriedades?incluirInativas= Lista, com a contagem de valores já gravados
POST /api/tipos-de-compromisso/{tipo}/propriedades Cria — a Chave é gerada a partir do rótulo
PUT .../{propriedade}/rotulo Renomeia a exibição; a chave permanece
PUT .../{propriedade}/obrigatoriedade · /ordem Ajusta a regra e a posição no formulário
PUT .../{propriedade}/tipo-de-dado Bloqueado se já existir valor gravado
POST .../{propriedade}/inativacao · /reativacao Soft delete e volta

Compromissos

Verbo Rota O que faz
GET /api/compromissos/{id} O compromisso completo em um único JSON
POST /api/compromissos Grava o compromisso e seus valores tipados

Corpo da gravação:

{
  "identificadorDoTipoCompromisso": "A0000000-0000-0000-0000-000000000001",
  "dataHora": "2026-09-02T09:30:00",
  "descricao": "Visita — Serralheria Praia de Iracema",
  "valores": [
    { "chave": "VALOR_SOLICITADO", "valor": "62500,90" },
    { "chave": "DATA_VISITA",      "valor": "02/09/2026" },
    { "chave": "POSSUI_GARANTIA",  "valor": "sim" }
  ]
}

Cada valor identifica a propriedade por chave ou por identificadorDaPropriedade, e manda o conteúdo como texto — é a aplicação que escolhe a coluna do EAV, a partir do TipoDado declarado no metamodelo. Formatos aceitos: decimal com vírgula ou ponto; data em aaaa-MM-dd ou dd/MM/aaaa; booleano em true, false, 1, 0, sim, nao.

Validações da aplicação, todas devolvidas juntas em inconsistencias (HTTP 400):

Situação Por que é da aplicação, e não do banco
Propriedade não pertence ao tipo O banco também bloqueia, pela FK composta — aqui a mensagem é legível
Valor incompatível com o TipoDado O CHECK garante uma coluna preenchida, não qual
Propriedade obrigatória não preenchida Obrigatoriedade é definida em runtime, não é NOT NULL
Propriedade repetida no mesmo payload A PK composta também bloqueia
Propriedade inativa recebendo valor novo Soft delete preserva histórico, mas não aceita gravação nova

Códigos de retorno: 400 requisição inconsistente · 404 recurso não encontrado · 409 regra de negócio violada.


Como rodar

Na raiz do repositório:

docker compose up -d --build

O projeto roda exclusivamente via Docker Compose. Não há caminho de execução local.

Isso sobe, nesta ordem: SQL Server, o inicializador (que cria o banco e o esquema e encerra), o DBGate e a API — que só inicia depois que o inicializador termina com sucesso, garantido por depends_on: condition: service_completed_successfully. Sem migrations, essa ordem não é detalhe: o seed do startup precisa de um esquema pronto.

Endereços padrão:

Serviço URL / Porta Credenciais
API http://localhost:8080
API (OpenAPI) http://localhost:8080/openapi/v1.json
DBGate http://localhost:8090 conexão já registrada
SQL Server localhost:1433 sa / SenhaForte@2026 (db DemonstracaoPadraoEav)

Senhas, nome do banco e portas ficam em .env.

Para parar:

docker compose down

Os volumes dados-do-sql-server e dados-do-dbgate persistem entre reinícios. Para começar do zero (banco vazio, esquema e seed reaplicados): docker compose down -v.

Divisão de responsabilidades

Etapa Quem faz
CREATE DATABASE, tabelas, constraints e índices docker/inicializacao/01-criar-esquema.sql, via sqlcmd
Carga do metamodelo e dos compromissos A própria aplicação, no startup (DadosIniciaisSeed)
Consultas de leitura, filtro, validação e integridade Você, no DBGate — seções 5 a 10 de anexos/script.sql

O 01-criar-esquema.sql é idempotente (IF OBJECT_ID(...) IS NULL), então docker compose up repetido não derruba nada.

Seed de dados iniciais

O DadosIniciaisSeed reproduz exatamente os INSERT das seções 3 e 4 do script.sql — mesmos identificadores, mesmos valores.

Situação Resultado
Banco inacessível Registra aviso no log e a aplicação sobe sem gravar
Tabelas do modelo inexistentes Registra aviso pedindo a execução do script e não grava
Já existe qualquer TipoCompromisso Não grava nada (idempotente)
Banco vazio Grava em transação única; qualquer falha desfaz tudo
"Seeds": {
  "Habilitada": true,
  "IncluirCompromissosDeDemonstracao": true
}

IncluirCompromissosDeDemonstracao = false carrega só o metamodelo, deixando Compromisso e CompromissoValor vazios — útil para criar os compromissos ao vivo pela API.

O seed preserva de propósito uma lacuna: o compromisso Marcenaria Beira-Mar fica sem a propriedade obrigatória DATA_VISITA. É ela que dá o que mostrar no relatório de pendências.


Testando com a collection do Insomnia

O arquivo está em anexos/colecao-insomnia.json43 requests em 4 pastas, com o environment Ambiente Local já configurado (urlBase e os identificadores do seed).

  1. No Insomnia: Application → Preferences → Data → Import Data → From File.

  2. Selecione o environment Ambiente Local.

  3. As pastas 1 a 3 funcionam isoladas, contra os dados do seed. A pasta 4 é a trilha guiada.

    • 1 — Tipos de Compromisso: CRUD do metamodelo, incluindo a exclusão física recusada (409).
    • 2 — Propriedades do Tipo: renomear rótulo mantendo a chave, alterar obrigatoriedade e ordem, soft delete, e a troca de tipo de dado bloqueada (409).
    • 3 — Compromissos: leitura completa, duas gravações e quatro tentativas que devem falhar (400).
    • 4 — Fluxo ponta a ponta: 16 requests encadeados por resposta.

Demonstração ponta a ponta (roteiro)

A pasta 4 é a da apresentação ao vivo. Rode de cima para baixo:

  1. 01–04 criam um tipo que não existia e três propriedades de tipos de dado diferentes (Decimal, Data, Booleano). Repare nas chaves geradas: VALOR_AVALIADO_DO_BEM, DATA_DA_VISTORIA, LAUDO_EMITIDO.
  2. 05 lê o formulário dinâmico do tipo recém-criado. Nenhuma tabela foi criada no caminho.
  3. 06–07 gravam e releem um compromisso. Confira colunaDeValorUtilizada: ValorNumero, ValorData e ValorBool, cada um na coluna certa.
  4. 08–10 provam os três bloqueios de gravação: obrigatória faltando, valor incompatível com o tipo, propriedade de outro tipo.
  5. 11 renomeia um rótulo e 12 tenta trocar o tipo de dado de uma propriedade que já tem valor — recusado.
  6. 13–15 inativam uma propriedade e releem: ela some do formulário (passo 15) mas continua no compromisso (passo 14), com propriedadeAtiva: false. É a diferença entre a tela de cadastro e o histórico.
  7. 16 inativa o tipo, fechando o ciclo.

O nome do tipo criado no passo 01 recebe um sufixo aleatório, então o fluxo pode ser repetido quantas vezes você quiser. Os requests encadeados usam a última resposta armazenada, então precisam ser executados em ordem ao menos uma vez.


Verificação no banco

-- Metamodelo: o que o usuário definiu
SELECT * FROM dbo.TipoCompromisso;
SELECT * FROM dbo.PropriedadeTipo ORDER BY TipoCompromissoId, Ordem;

-- Dados
SELECT * FROM dbo.Compromisso ORDER BY DataHora;
SELECT * FROM dbo.CompromissoValor;

-- Formulário dinâmico: o que a tela renderiza para um tipo
SELECT p.Ordem, p.Chave, p.Rotulo, p.TipoDado, p.Obrigatoria
FROM   dbo.PropriedadeTipo p
WHERE  p.TipoCompromissoId = 'A0000000-0000-0000-0000-000000000001'
  AND  p.Ativa = 1
ORDER BY p.Ordem;

-- Filtro numérico com ordenação: o que uma coluna VARCHAR única quebraria
SELECT c.Descricao, ValorSolicitado = v.ValorNumero
FROM   dbo.CompromissoValor v
JOIN   dbo.Compromisso c ON c.Id = v.CompromissoId
WHERE  v.PropriedadeId = 'B0000000-0000-0000-0000-000000000001'
  AND  v.ValorNumero > 50000
ORDER BY v.ValorNumero DESC;

-- Relatório de pendências: obrigatórias não preenchidas
SELECT c.Descricao, Tipo = t.Nome, PropriedadeVazia = p.Rotulo
FROM   dbo.Compromisso c
JOIN   dbo.TipoCompromisso t    ON t.Id = c.TipoCompromissoId
JOIN   dbo.PropriedadeTipo p    ON p.TipoCompromissoId = c.TipoCompromissoId
                               AND p.Obrigatoria = 1 AND p.Ativa = 1
LEFT JOIN dbo.CompromissoValor v ON v.CompromissoId = c.Id AND v.PropriedadeId = p.Id
WHERE  v.CompromissoId IS NULL;

-- Auditoria: valor gravado na coluna errada para o TipoDado (deve vir vazio)
SELECT Compromisso = c.Descricao, Propriedade = p.Rotulo, p.TipoDado
FROM   dbo.CompromissoValor v
JOIN   dbo.PropriedadeTipo p ON p.Id = v.PropriedadeId
JOIN   dbo.Compromisso c     ON c.Id = v.CompromissoId
WHERE  NOT (
       (p.TipoDado = 'TEXTO'                AND v.ValorTexto  IS NOT NULL) OR
       (p.TipoDado IN ('DECIMAL','INTEIRO') AND v.ValorNumero IS NOT NULL) OR
       (p.TipoDado = 'DATA'                 AND v.ValorData   IS NOT NULL) OR
       (p.TipoDado = 'BOOL'                 AND v.ValorBool   IS NOT NULL));

As seções 8 a 10 de anexos/script.sql trazem as provas de integridade prontas — os INSERT que o banco deve recusar, cada um dentro de um TRY/CATCH que imprime o motivo. É o material mais direto para mostrar a FK composta funcionando.


Tecnologias

Base: .NET 10 · C# · ASP.NET Core (controllers) · OpenAPI

Persistência: SQL Server 2022 · EF Core 10 · Fluent API (sem migrations)

Infra local: Docker Compose · DBGate · mssql-tools

Testes manuais: Insomnia (collection versionada) · arquivo .http

Note o que não está aqui: nenhuma biblioteca de mapeamento, de validação ou de mediator. A conversão de valores, a geração de chave e a validação são do próprio projeto — de propósito, para que o mecanismo do EAV fique visível em vez de escondido atrás de um pacote.


Documentação complementar

Documento Conteúdo
anexos/modelagem.md A modelagem: diagrama entidade-relacionamento, cardinalidades, as restrições de integridade que sustentam o modelo, as regras de evolução do metamodelo e os índices sugeridos.
anexos/script.sql O laboratório em SQL puro: DDL, carga, consultas de leitura e pivot dinâmico, filtros tipados, validação, provas de integridade e evolução do metamodelo sem DDL.
anexos/colecao-insomnia.json Collection com 43 requests, incluindo a trilha guiada de 16 passos.
docker/inicializacao/01-criar-esquema.sql O DDL efetivamente aplicado pelo container inicializador.

O script.sql e a API são companheiros: o primeiro mostra o modelo em SQL puro, sem intermediários; a segunda mostra o que a aplicação precisa assumir por cima dele. Rodar as duas coisas lado a lado é o que fecha a demonstração.


Limitações (por ser didático)

Estas simplificações são intencionais para focar no aprendizado; em produção você trataria cada uma:

  • Segredos em texto claro no .env, no docker-compose.yml e no appsettings.json — inclusive a senha do sa, usada também pelo DBGate.
  • API sem autenticação/autorização, e o DBGate exposto sem senha.
  • Sem migrations: o esquema vem de um script SQL aplicado por fora. Uma mudança de tabela precisa entrar em três lugaresanexos/script.sql, docker/inicializacao/01-criar-esquema.sql e o mapeamento Fluent API.
  • Escopo da API incompleto: não há listagem paginada de compromissos, edição, exclusão nem o endpoint de pivot tabular. O pivot dinâmico existe só no script.sql — é o custo real do EAV e vale mostrar em SQL.
  • Sem testes automatizados. As garantias do modelo são demonstradas manualmente, pela collection e pelas provas de integridade do script.
  • Sem paginação, cache ou limite de resultados em nenhuma consulta.
  • Sem histórico de alteração de valores: CompromissoValor guarda o estado atual, não a trilha de quem mudou o quê e quando.
  • Sem controle de concorrência: não há rowversion nem verificação otimista; duas gravações simultâneas no mesmo compromisso não conflitam explicitamente.
  • TipoDado é validado pela aplicação, não por constraint entre tabelas. O CHECK garante que só uma coluna de valor está preenchida, mas não que seja a coerente com o tipo declarado — a seção 10 do script.sql discute como fechar essa lacuna com coluna computada persistida.
  • Seed com identificadores fixos, alinhados aos do script.sql — sem estratégia de geração distribuída.

About

Este repositório é uma prova de conceito, criada para demonstrar o padrão Entity-Attribute-Value (EAV) Ideal para sistemas onde os objetos possuem muitas propriedades opcionais ou variáveis que mudam com frequência

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages