Montar o mapeamento objeto relacional da maneira correta é uma das dificuldades que enfrentamos quando estamos trabalhando com bibliotecas ORM. Testar o mapeamento sempre que alteramos é outra dificuldade. Portanto, visando te ajudar nesse momento, vou explicar alguns detalhes importantes de como criar um mapeamento. E, ao final, criaremos um teste de integração testando o mapeamento.

No exemplo, utilizarei o NHibernate como ORM e o Fluent NHibernate , para gerar os mapeamentos de classe em forma de código tipado, no lugar dos arquivos
.hbm.xml. Para o de banco de dados, utilizarei o conhecido banco Northwind, lembrando que seu script de criação se encontra no meu Github e junto deixei um docker compose com o banco já construido.

Vamos ao Código

Primeiramente, é preciso adicionar o Fluent NHibernate no nosso projeto.

dotnet add package FluentNHibernate 

Como falei anteriormente, o Fluent NHibernate torna possível criar o mapeamento sem os tradicionais arquivos .hbm.xml, substituindo por classes C#. Segue abaixo a entidade e o mapeamento da classe Categories:

    public class Categories
    {
        public virtual int CategoryID { get; set; }
        public virtual IList<Products> Products { get; set; }
        public virtual string CategoryName { get; set; }
        public virtual string Description { get; set; }
        public virtual byte[] Picture { get; set; }
    }
    public class CategoriesMap : ClassMap<Categories>
    {
        public CategoriesMap()
        {
            Table("[Categories]");
            Id(x => x.CategoryID, "[CategoryID]").GeneratedBy.Identity();
            HasMany(x => x.Products)
                .Table("[Products]")
                .KeyColumn("[CategoryID]")
                .ForeignKeyConstraintName("[Products.FK_Products_Categories]");
            Map(x => x.CategoryName, "[CategoryName]").Not.Nullable().Length(30);
            Map(x => x.Description, "[Description]").Nullable().Length(16);
            Map(x => x.Picture, "[Picture]").Nullable().Length(16);
        }
    }

Como podem ver, a classe de mapeamento deve herdar a classe genérica ClassMap e a configuração do mapeamento é feita no seu construtor.

Aqui, já é possível verificar alguns tipos de mapeamento que são feitos.

Table

Table como o nome mesmo sugere, é onde configuramos o nome da tabela. Para entidades em que o nome da tabela são iguais, esse parâmetro é opcional.

Id

Id é a chave primária. Toda entidade precisa ter pelo menos um Id. No caso de chave composta, terá mais de um. O Id pode ser gerado automaticamente pelo banco ou não. Para configurar as várias formas de geração de chave, utilize .GeneratedBy, e as opções você pode encontrar no manual do NHibernate. No caso da coluna e a propriedate possuirem nomes iguais, não é necessario utilizar especificar o nome da coluna.

Exemplo:

Id(x => x.CategoryID, "[CategoryID]").GeneratedBy.Identity();

Map

Map é utilizado para mapear as propriedades que possuem referência a alguma coluna da tabela. A regra para o nome da coluna é a mesma, porém, deve-se configurar se a coluna aceita nulo e o tamanho.

Exemplo:

Map(x => x.CategoryName, "[CategoryName]").Not.Nullable().Length(30);

Mapeando os relacionamentos

Agora o próximo passo é configurar os relacionamentos entre as entidades. Vou utilizar alguns exemplos do modelo e a implementação completa vai estar no meu Github.

References / Many-To-One

Iniciaremos pelo relacionamento mais simples, quando possuo um relacionamento simples com outra entidade. Para esses casos é utilizado o References. A entidade Products será o exemplo:

    public class Products
    {

        public Products()
        {
            this.OrdersDetail = new List<OrderDetails>();
        }

        public virtual int ProductID { get; set; }
        public virtual string ProductName { get; set; }
        public virtual Suppliers Supplier { get; set; }
        public virtual Categories Category { get; set; }
        public virtual string QuantityPerUnit { get; set; }
        public virtual IList<OrderDetails> OrdersDetail { get; set; }
        public virtual decimal UnitPrice { get; set; }
        public virtual int UnitsInStock { get; set; }
        public virtual int UnitsOnOrder { get; set; }
        public virtual int ReorderLevel { get; set; }
        public virtual bool Discontinued { get; set; }
    }

Existem muitas propriedades para mapear na entidade Products, entretanto, vamos focar no relacionamento com Categories. Para mapear esse relacionamento, utilizaremos o References.

public class ProductsMap : ClassMap
{
public ProductsMap()
{
References(x => x.Category)
.ForeignKey(“[Products.FK_Products_Categories]”)
.Columns(“[CategoryID]”);
}
}

Como podem ver, a configuração é bem simples. É configurado o nome da chave estrangeira e o nome da coluna. Além disso, é possível configurar o cascateamento (Cascade.All(), Cascade.None(), Cascade.Delete(), Cascade.SaveUpdate(), Cascade.DeleteOrphans()), ou seja, qual comportamento será cascateado da entidade pai. Para mais detalhes, consulte a documentação.


HasMany / One-To-Many

Agora é o momento de configurarmos a entidade Categories. Como podem ver no código abaixo, ela possui um relacionamento com Products.

    public class Categories
    {
        public Categories()
        {
            this.Products = new List<Products>();
        }
        public virtual int CategoryID { get; set; }
        public virtual IList<Products> Products { get; set; }
        public virtual string CategoryName { get; set; }
        public virtual string Description { get; set; }
        public virtual byte[] Picture { get; set; }
    }

Porém, uma categoria possui muitos produtos relacionados. Em casos como esse, utilizamos o HasMany.

    public class CategoriesMap : ClassMap<Categories>
    {
        public CategoriesMap()
        {
            HasMany(x => x.Products)
                .Table("[Products]")
                .KeyColumn("[CategoryID]")
                .ForeignKeyConstraintName("[Products.FK_Products_Categories]");
        }
    }

Novamente, ignorando os outros mapeamentos, podemos ver que para configurar o mapeamento é preciso configurar a tabela de origem, a chave estrangeira e o nome da chave. Como complemento, podemos inserir as configurações de cascade e utilizar o inverse. Com ele, a entidade produtos será responsável por salvar os dados de categoria. Mais detalhes na documentação.

HasManyToMany / Many-To-Many

Agora vamos para o relacionamento onde as duas entidades possuem muitos relacionamentos entre si. Como exemplo, utilizarei as entidades Employees e Territories.

    public class Employees
    {
        public Employees()
        {
            this.Territories = new List<Territories>();
            this.Subordinates = new List<Employees>();
            this.Orders = new List<Orders>();
        }
        public virtual int EmployeeID { get; set; }
        public virtual string LastName { get; set; }
        public virtual string FirstName { get; set; }
        public virtual string Title { get; set; }
        public virtual string TitleOfCourtesy { get; set; }
        public virtual DateTime BirthDate { get; set; }
        public virtual IList<Territories> Territories { get; set; }
        public virtual DateTime HireDate { get; set; }
        public virtual string Address { get; set; }
        public virtual string City { get; set; }
        public virtual string Region { get; set; }
        public virtual string PostalCode { get; set; }
        public virtual string Country { get; set; }
        public virtual string HomePhone { get; set; }
        public virtual string Extension { get; set; }
        public virtual byte[] Photo { get; set; }
        public virtual string Notes { get; set; }
        public virtual IList<Employees> Subordinates { get; set; }
        public virtual IList<Orders> Orders { get; set; }
        public virtual Employees Supervisor { get; set; }
        public virtual string PhotoPath { get; set; }
    }

    public class Territories
    {
        public Territories()
        {
            this.Employees = new List<Employees>();
        }
        public virtual string TerritoryID { get; set; }
        public virtual string TerritoryDescription { get; set; }
        public virtual IList<Employees> Employees { get; set; }
        public virtual Region Region { get; set; }
    }

Para relacionamentos Many-To-Many, utilizamos o HasManyToMany.

    public class EmployeesMap : ClassMap<Employees>
    {
        public EmployeesMap()
        {
            HasManyToMany(x => x.Territories)
                .Table("[EmployeeTerritories]")
                .ParentKeyColumn("[EmployeeID]")
                .ChildKeyColumn("[TerritoryID]");
        }
    }

    public class TerritoriesMap : ClassMap<Territories>
    {
        public TerritoriesMap()
        {
            HasManyToMany(x => x.Employees)
                .Table("[EmployeeTerritories]")
                .ParentKeyColumn("[TerritoryID]")
                .ChildKeyColumn("[EmployeeID]");
        }
    }

Aqui, temos que levar em consideração que existe uma terceira tabela EmployeeTerritories, que faz a ligação entre os relacionamentos. Como no HasMany, podemos utilizar o Inverse e o Cascade. Mais detalhes na documentação.

Ok…. Como eu testo ?

Agora que passamos por todos os tipos de mapeamentos do nosso modelo, hora de testarmos. Pensando nisso, o Fluent NHibernate possui a classe
PersistenceSpecification para efetuar testes.

Vamos efetuar um teste com a entidade Employees. Para isso, preciso de um projeto de teste xUnit e crio o teste abaixo:

    public class EmployeeTest
    {
        [Fact]
        public void MappingTest()
        {
            var sessionFactory = Fluently.Configure()
                .Database(MsSqlConfiguration.MsSql2012.ConnectionString("Data Source=localhost;Initial Catalog=Northwind;User ID=sa;Password=Northwing0123"))
                .Mappings(m =>
                  m.FluentMappings
                    .AddFromAssemblyOf<EmployeesMap>())
                .BuildSessionFactory();

            using (var nh = sessionFactory.OpenSession())
            {
                var birthDate = DateTime.Now;
                var hireDate = DateTime.Now;

                new PersistenceSpecification<Employees>(nh, new ComparadorCustomizado())
                    .CheckProperty(c => c.Address, "Rua de teste, 45")
                    .CheckProperty(c => c.BirthDate, DateTime.Now)
                    .CheckProperty(c => c.City, "Rio de Janeiro")
                    .CheckProperty(c => c.Country, "Brazil")
                    .CheckProperty(c => c.Extension, "5176")
                    .CheckProperty(c => c.FirstName, "Fulano")
                    .CheckProperty(c => c.HireDate, DateTime.Now)
                    .CheckProperty(c => c.HomePhone, "99999-9999")
                    .CheckProperty(c => c.LastName, "De Tal")
                    .CheckProperty(c => c.Notes, "Programador .NET")
                    .CheckProperty(c => c.Supervisor, new Employees() { EmployeeID = 1 }, new ComparadorEmployees())
                    .CheckProperty(c => c.Subordinates, new List<Employees>() {
                        new Employees(){ EmployeeID = 2 },
                        new Employees(){ EmployeeID = 3 }
                    }, new ComparadorEmployees())
                    .CheckProperty(c => c.PhotoPath, "c")
                    .CheckProperty(c => c.PostalCode, "21921380")
                    .CheckProperty(c => c.Region, "RIO")
                    .CheckProperty(c => c.Title, "Programador")
                    .CheckProperty(c => c.TitleOfCourtesy, "Sr.")
                    .VerifyTheMappings();
            }
        }
    }

Esse teste vai seguir os seguintes passos:

  • Criar uma instancia da classe Employees
  • Inserir a instancia no banco
  • Retornar como uma nova instancia
  • Comparar o retorno com o original

Notem que, em algumas verificações, eu passei como parâmetro uma instancia da classe ComparadorEmployees e da classe ComparadorData. Isso, porque a comparação é feita utilizando o método Object.Equals(). Ou seja, em tipos primitivos, funciona como esperamos, mas no caso de objetos, ele irá comparar a referencia e sempre será falso.

Para resolver isso, implementei 2 classes que herdam de IEqualityComparer, uma para comparar datas e outra para comparar Employees.

    public class ComparadorData : IEqualityComparer
    {
        public new bool Equals(object x, object y)
        {
            if (x is DateTime && y is DateTime)
            {
                return x.ToString().Equals(y.ToString());
            }
            else 
            {
                return x.Equals(y);
            }
        }

        public int GetHashCode(object obj)
        {
            throw new NotImplementedException();
        }
    }
    public class ComparadorEmployees : IEqualityComparer
    {
        public new bool Equals(object x, object y)
        {
            if (x is Employees && y is Employees)
            {
                return this.Compare((Employees)x, (Employees)y);
            }
            else if (x is IEnumerable<Employees> && y is IEnumerable<Employees>)
            {
                var xList = ((IEnumerable<Employees>)x).OrderBy(e => e.EmployeeID).ToArray();
                var yList = ((IEnumerable<Employees>)y).OrderBy(e => e.EmployeeID).ToArray();

                if (xList.Count() != yList.Count()) return false;

                for (int i = 0; i < xList.Count(); i++)
                {
                    if (xList[i].EmployeeID != yList[i].EmployeeID)
                    {
                        return false;
                    }
                }

                return true;
            }

            return x.Equals(y);
        }

        private bool Compare(Employees x, Employees y)
        {
            return x.EmployeeID == y.EmployeeID;
        }

        public int GetHashCode(object obj)
        {
            throw new NotImplementedException();
        }
    }

Feito isso, podemos executar os testes e ver se o mapeamento foi feito de forma correta.

Esse teste é muito útil sempre que o mapeamento for alterado e cabe inclui-lo em seus testes de integração.

Espero que tenham gostado da dica. Todo o projeto, o banco em docker-compose e o script de criação se encontram no meu GitHub. Como sempre, dúvidas e sugestões são sempre muito bem vindas.