Cambiar nombres de propiedades de navegación en base de datos con EFCore

Es bastante común que la nomenclatura que se use en código, habitualmente Camel (CustomerId), no sea la misma que usemos en base de datos (CUSTOMER_ID) no voy a entrar a valorar cual es mejor o peor pero es conocido que es un hecho bastante habitual.

EFCore proporciona muchos mecanismos para realizar estos mapeos y poder elegir el nombre que se prefiera pero me he encontrado con el siguiente caso (muy habitual).

public class Project 
{
    public int ProjectId { get; set; }
    public string Name { get; set; }
    public string Description { get; set; }
    public DateTime? End { get; set; }
    public DateTime Start { get; set; }
    public Customer Customer { get; set; }
}

Soy fan de FluentAPI y no me gusta meter anotaciones dentro de las clases, tampoco voy a entrar a discutir esto, así que me creo una clase de mapeo tal que:

public class ProjectMapping : IEntityTypeConfiguration<Project>
{
    public void Configure(EntityTypeBuilder<Project> builder)
    {
        builder.ToTable("PROJECT");
        builder.HasKey(c => c.ProjectId);
        builder.Property(c => c.ProjectId).HasColumnName("PROJECT_ID");

        builder.Property(c => c.Name).IsRequired().HasColumnName("NAME");
        builder.Property(c => c.Description).HasColumnName("DESCRIPTION");
        builder.Property(c => c.End).HasColumnName("END_DATE");
        builder.Property(c => c.Start).IsRequired().HasColumnName("START_DATE");

        builder.HasOne(x => x.Customer)
         .WithMany(y => y.Projects)
         .OnDelete(DeleteBehavior.Cascade);
}

Haciendo las migraciones, etc… nos crearía un SQL como:

CREATE TABLE [PROJECT] (
    [PROJECT_ID] int NOT NULL IDENTITY,
    [NAME] nvarchar(max) NOT NULL,
    [DESCRIPTION] nvarchar(max) NULL,
    [END_DATE] datetime2 NULL,
    [START_DATE] datetime2 NOT NULL,
    [CustomerId] int NULL,
    CONSTRAINT [PK_PROJECT] PRIMARY KEY ([PROJECT_ID]),
    CONSTRAINT [FK_PROJECT_CUSTOMER_CustomerId] FOREIGN KEY ([CustomerId]) REFERENCES [CUSTOMER] ([CUSTOMER_ID]) ON DELETE CASCADE
);

Vemos que todos los nombres son acordes a nuestra nomenclatura menos CustomerId que ha sido creada con el nombre por defecto de las convenciones, si quisiéramos cambiarlo podríamos añadir una propiedad CustomerId a la entidad mapearla como campo poniendo entonces el nombre que queramos y luego haciendo que la foreign key apunte a esta propiedad:

builder.Property(c => c.CustomerId).HasColumnName("CUSTOMER_ID");
builder.HasOne(x => x.Customer)
                .WithMany(y => y.Projects)
                .HasForeignKey(z=>z.CustomerId)
                .OnDelete(DeleteBehavior.Cascade);

Y esto nos sacaría el SQL que queremos:

CREATE TABLE [PROJECT] (
    [PROJECT_ID] int NOT NULL IDENTITY,
    [NAME] nvarchar(max) NOT NULL,
    [DESCRIPTION] nvarchar(max) NULL,
    [END_DATE] datetime2 NULL,
    [START_DATE] datetime2 NOT NULL,
    [CUSTOMER_ID] int NOT NULL,
    CONSTRAINT [PK_PROJECT] PRIMARY KEY ([PROJECT_ID]),
    CONSTRAINT [FK_PROJECT_CUSTOMER_CUSTOMER_ID] FOREIGN KEY ([CUSTOMER_ID]) REFERENCES [CUSTOMER] ([CUSTOMER_ID]) ON DELETE CASCADE
);

Ahora CustomerId tiene el nombre correcto que buscamos y además la clave foránea apunta a dicho campo. La verdad es que hasta aquí nada nuevo, el problema o la duda que me surgía era ¿se puede omitir crear la propiedad CustomerId? siendo realistas aunque puedo darle algún uso solo la he creado para poder mapear con un nombre acorde al resto de los campos de la tabla.

EFCore da más opciones con los llamados backing fields así que ahora si se puede hacer, lo que se hace es crear un campo shadow con el nombre que queramos y después usar dicho nombre en el mapeo, así que borramos la propiedad que hemos creado antes y hacemos lo siguiente:

  builder.Property<int>("CUSTOMER_ID"); // Genero un shadow
  builder.HasOne(x => x.Customer)
                .WithMany(y => y.Projects)
                .HasForeignKey("CUSTOMER_ID") // Lo uso aquí :)
                .OnDelete(DeleteBehavior.Cascade);

Esto nos genera exactamente el mismo SQL que en el caso anterior.

CREATE TABLE [PROJECT] (
    [PROJECT_ID] int NOT NULL IDENTITY,
    [NAME] nvarchar(max) NOT NULL,
    [DESCRIPTION] nvarchar(max) NULL,
    [END_DATE] datetime2 NULL,
    [START_DATE] datetime2 NOT NULL,
    [CUSTOMER_ID] int NOT NULL,
    CONSTRAINT [PK_PROJECT] PRIMARY KEY ([PROJECT_ID]),
    CONSTRAINT [FK_PROJECT_CUSTOMER_CUSTOMER_ID] FOREIGN KEY ([CUSTOMER_ID]) REFERENCES [CUSTOMER] ([CUSTOMER_ID]) ON DELETE CASCADE
);

Yendo un poco más lejos, ¿se podría hacer esto mismo con las tablas intermedias? así tendría solo objectos como propiedades de navegación y no tendría que tener propiedades extras para los mapeos de los campos de identidad.

En este caso hay un problema adicional, la clave primaria de la tabla intermedia suele estar compuestas por las claves primarias de las tablas a las que hace referencia y no se puede usar un objeto como clave primaria en el mapeo por lo tanto al quitarlas y dejar solo objetos no podría crear la clave primaria.

Resulta que ambos problemas se solucionan de la misma forma, para una entidad tal que:

    public class ProjectResource
    {
        public Resource Resource { get; set; }
        public Project Project { get; set; }
    }

Hacemos lo siguiente:

    public class ProjectResourceMapping : IEntityTypeConfiguration<ProjectResource>
    {
       public void Configure(EntityTypeBuilder<ProjectResource> builder)
       {
            builder.ToTable("PROJECT_RESOURCE");

            builder.Property<int>("PROJECT_ID"); // Campo que apunta a la tabla PROJECTS
            builder.Property<int>("RESOURCE_ID"); // Campo que apunta a la tabla RESOURCES

            builder.HasKey("PROJECT_ID", "RESOURCE_ID"); // Los uso para crear la clave primaria

            builder.HasOne(c => c.Resource)
                   .WithMany(d => d.ProjectResources)
                   .HasForeignKey("RESOURCE_ID"); // Y como clave foránea de RESOURCES
            builder.HasOne(c => c.Project)
                   .WithMany(d => d.ProjectResources)
                   .HasForeignKey("PROJECT_ID"); // y de PROJECTS
        }
    }

Ahora hay que hacer lo mismo en las tablas referenciadas para que sepan cual es el campo al que tienen que apuntar en la tabla intermedia, de otra forma crearían referencias duplicadas, cogemos de ejemplo la tabla RESOURCES (la otra tabla sería igual).

public class ResourceMapping : IEntityTypeConfiguration<Resource>
{
    public void Configure(EntityTypeBuilder<Resource> builder)
    {
        builder.ToTable("RESOURCE");
        builder.HasKey(c => c.ResourceId);

        builder.HasMany(c => c.ProjectResources)
               .WithOne(d => d.Resource)
               .HasForeignKey("RESOURCE_ID"); // El mismo nombre que hemos elegido antes
    }
}

Con esto estaría resuelto, además tendríamos todas las ventajas de los campos shadow.

Un detalle importante, las propiedades shadow  tienen que aparecer antes que las propiedades que las hacen referencia, ya sea una clave foránea o una clave primaria, de otro modo dará un error, supongo que son cosas del directo.

¡Hasta otra!

Cambiar nombres de propiedades de navegación en base de datos con EFCore