Entity Framework/Core y LINQ to Entities (2) Base de datos de modelado:Asignación relacional de objetos

Entity Framework/Core y LINQ to Entities (2) Base de datos de modelado:Asignación relacional de objetos

[LINQ a través de la serie C#]

[Serie principal de Entity Framework]

[Serie Entity Framework]

Última versión de EF Core de este artículo: https://weblogs.asp.net/dixin/entity-framework-core-and-linq-to-entities-2-modeling-database-object-relational-mapping

Versión EF de este artículo: https://weblogs.asp.net/dixin/entity-framework-and-linq-to-entities-3-logging

Base de datos .NET y SQL y tiene 2 sistemas de tipos de datos diferentes. Por ejemplo, .NET tiene System.Int64 y System.String, mientras que la base de datos SQL tiene bigint y nvarchar; .NET tiene secuencias y objetos, mientras que la base de datos SQL tiene tablas y filas, etc. El mapeo relacional de objetos es una tecnología popular para mapear y convertir entre objetos de datos de aplicaciones y datos relacionales de bases de datos. En LINQ to Entities, las consultas se basan en el mapeo relacional de objetos.

En comparación con la generación de código a partir de modelos de datos de entidad (.edmx), es más intuitivo y transparente crear código desde cero. Además, con respecto a que EF Core no admite modelos de datos de entidad (.edmx) y solo admite el código primero, este tutorial sigue el enfoque del código primero.

Tipos de datos

EF/Core puede asignar la mayoría de los tipos de datos SQL a tipos .NET:

categoría de tipo SQL Tipo SQL Tipo .NET C# primitivo
Número exacto bit Sistema.Booleano bool
intínimo Sistema.Byte byte
int pequeño Sistema.Int16 corto
int Sistema.Int32 int
grande Sistema.Int64 largo
pequeñodinero, dinero, decimal, numérico Sistema.Decimal decimal
Número aproximado real Sistema.Único flotante
flotante Sistema.Doble doble
Cadena de caracteres char, varchar, texto Sistema.Cadena cadena
nchar, nvarchar, ntext Sistema.Cadena cadena
Cadena binaria binario, varbinario Sistema.Byte[] byte[]
imagen Sistema.Byte[] byte[]
rowversion (marca de tiempo) Sistema.Byte[] byte[]
Fecha y hora fecha Sistema.FechaHora
tiempo Sistema.Lapso de tiempo
fechahorapequeña, fechahora, fechahora2 Sistema.FechaHora
desplazamiento de fecha y hora Sistema.DateTimeOffset
Tipo espacial geografía Sistema.Datos.Entidad.Espacial.DbGeografía*
geometría Sistema.Datos.Entidad.Espacial.DbGeometría*
Otro id de jerarquía Sin mapeo o soporte integrado
xml Sistema.Cadena cadena
identificador único Sistema.Guid
variante_sql Sin mapeo o soporte integrado

Base de datos

Una base de datos SQL se asigna a un tipo derivado de DbContext:

public partial class AdventureWorks : DbContext { }

DbContext se proporciona como:

namespace Microsoft.EntityFrameworkCore
{
    public class DbContext : IDisposable, IInfrastructure<IServiceProvider>
    {
        public DbContext(DbContextOptions options);

        public virtual ChangeTracker ChangeTracker { get; }

        public virtual DatabaseFacade Database { get; }

        public virtual void Dispose();

        public virtual int SaveChanges();

        public virtual DbSet<TEntity> Set<TEntity>() where TEntity : class;

        protected internal virtual void OnModelCreating(ModelBuilder modelBuilder);

        // Other members.
    }
}

DbContext implementa IDisposable. Por lo general, se debe construir y desechar una instancia de base de datos para cada unidad de trabajo:una colección de operaciones de datos que deben tener éxito o fallar como una unidad:

internal static void Dispose()
{
    using (AdventureWorks adventureWorks = new AdventureWorks())
    {
        // Unit of work.
    }
}

En EF/Core, la mayor parte de la asignación relacional de objetos se puede implementar de forma declarativa, y el resto de la asignación se puede implementar de manera imperativa anulando DbContext.OnModelCreating, que EF/Core llama al inicializar los modelos de entidad:

public partial class AdventureWorks
{
    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        base.OnModelCreating(modelBuilder);

        MapCompositePrimaryKey(modelBuilder);
        MapManyToMany(modelBuilder);
        MapDiscriminator(modelBuilder);
    }
}

Los métodos anteriores MapCompositePrimaryKey, MapManyToMany, MapDiscriminator se implementan poco después.

Resistencia de conexión y estrategia de reintento de ejecución

Al igual que el mapeo de la base de datos, AdventureWorks también gestiona la conexión a la base de datos, que se puede inyectar desde el constructor:

public partial class AdventureWorks
{
    public AdventureWorks(DbConnection connection = null)
        : base(new DbContextOptionsBuilder<AdventureWorks>().UseSqlServer(
            connection: connection ?? new SqlConnection(ConnectionStrings.AdventureWorks),
            sqlServerOptionsAction: options => options.EnableRetryOnFailure(
                maxRetryCount: 5, maxRetryDelay: TimeSpan.FromSeconds(30), errorNumbersToAdd: null)).Options) { }
}

Aquí, cuando la conexión a la base de datos no se proporciona al constructor, se crea una nueva conexión a la base de datos con la cadena de conexión definida anteriormente. Además, con respecto a la conexión entre la aplicación y la base de datos SQL puede interrumpirse (debido a la red, etc.), EF/Core admite resistencia de conexión para la base de datos SQL. Esto es especialmente útil para la base de datos Azure SQL implementada en la nube en lugar de la red local. En el ejemplo anterior, EF Core está especificado para reintentar automáticamente hasta 5 veces con un intervalo de reintento de 30 segundos.

Mesas

Hay decenas de tablas en la base de datos AdventureWorks, pero no se asuste, este tutorial solo involucra algunas tablas y algunas columnas de estas tablas. En EF/Core, una definición de tabla se puede asignar a una definición de tipo de entidad, donde cada columna se asigna a una propiedad de entidad. Por ejemplo, la base de datos AdventureWorks tiene una tabla Production.ProductCategory, que se define como:

CREATE SCHEMA [Production];
GO

CREATE TYPE [dbo].[Name] FROM nvarchar(50) NULL;
GO

CREATE TABLE [Production].[ProductCategory](
    [ProductCategoryID] int IDENTITY(1,1) NOT NULL
        CONSTRAINT [PK_ProductCategory_ProductCategoryID] PRIMARY KEY CLUSTERED,

    [Name] [dbo].[Name] NOT NULL, -- nvarchar(50).

    [rowguid] uniqueidentifier ROWGUIDCOL NOT NULL -- Ignored in mapping.
        CONSTRAINT [DF_ProductCategory_rowguid] DEFAULT (NEWID()),
    
    [ModifiedDate] datetime NOT NULL -- Ignored in mapping.
        CONSTRAINT [DF_ProductCategory_ModifiedDate] DEFAULT (GETDATE()));
GO

Esta definición de tabla se puede asignar a una definición de entidad ProductCategory:

public partial class AdventureWorks
{
    public const string Production = nameof(Production); // Production schema.
}

[Table(nameof(ProductCategory), Schema = AdventureWorks.Production)]
public partial class ProductCategory
{
    [Key]
    [DatabaseGenerated(DatabaseGeneratedOption.Identity)]
    public int ProductCategoryID { get; set; }

    [MaxLength(50)]
    [Required]
    public string Name { get; set; }

    // Other columns are ignored.
}

El atributo [Table] especifica el nombre y el esquema de la tabla. [Tabla] se puede omitir cuando el nombre de la tabla es el mismo que el nombre de la entidad y la tabla está bajo el esquema dbo predeterminado. En el mapeo de entidad de tabla:

  • La columna ProductCategoryID de tipo int se asigna a una propiedad System.Int32 con el mismo nombre. El atributo [Clave] indica que es una clave principal. EF/Core requiere que una tabla tenga una clave principal para mapear. [DatabaseGenerated] indica que es una columna de identidad, con un valor generado por la base de datos.
  • La columna Nombre es del tipo dbo.Name. que en realidad es nvarchar(50), por lo que se asigna a la propiedad Name de tipo System.String. El atributo [MaxLength] indica que la longitud máxima del valor de la cadena es 50. [Obligatorio] indica que no debe ser una cadena nula o vacía o una cadena de espacios en blanco.
  • Las otras columnas rowguid y ModifiedDate no están asignadas. No se utilizan en este tutorial para simplificar los ejemplos de código.

En tiempo de ejecución, cada fila de la tabla Production.ProductCategory se asigna a una instancia de ProductCategory.

Las filas de toda la tabla se pueden asignar a objetos en un origen de datos IQueryable, expuestos como una propiedad del tipo de base de datos. DbSet implementa IQueryable y se proporciona para representar una fuente de datos de tabla:

public partial class AdventureWorks
{
    public DbSet<ProductCategory> ProductCategories { get; set; }
}

Relaciones

En la base de datos SQL, las tablas pueden tener relaciones de clave externa, incluidas relaciones de uno a uno, de uno a muchos y de muchos a muchos.

Uno a uno

Las siguientes tablas Person.Person y HumanResources.Employee tienen una relación de uno a uno:

La columna BusinessEntityID de la tabla HumanResources.Employee es una clave externa que hace referencia a la clave principal de la tabla Person.Person:

CREATE TABLE [Person].[Person](
    [BusinessEntityID] int NOT NULL
        CONSTRAINT [PK_Person_BusinessEntityID] PRIMARY KEY CLUSTERED,

    [FirstName] [dbo].[Name] NOT NULL,

    [LastName] [dbo].[Name] NOT NULL

    /* Other columns. */);
GO

CREATE TABLE [HumanResources].[Employee](
    [BusinessEntityID] int NOT NULL
        CONSTRAINT [PK_Employee_BusinessEntityID] PRIMARY KEY CLUSTERED
        CONSTRAINT [FK_Employee_Person_BusinessEntityID] FOREIGN KEY
        REFERENCES [Person].[Person] ([BusinessEntityID]),
    
    [JobTitle] nvarchar(50) NOT NULL,

    [HireDate] date NOT NULL

    /* Other columns. */);
GO

Entonces, cada fila en la tabla HumanResources.Employee se refiere a una fila en la tabla Person.Person (un empleado debe ser una persona). Por otro lado, cada fila en la tabla Person.Person puede ser referida por 0 o 1 fila en la tabla HumanResources.Employee (una persona puede ser un empleado o no). Esta relación se puede representar mediante la propiedad de navegación del tipo de entidad:

public partial class AdventureWorks
{
    public const string Person = nameof(Person);

    public const string HumanResources = nameof(HumanResources);

    public DbSet<Person> People { get; set; }

    public DbSet<Employee> Employees { get; set; }
}

[Table(nameof(Person), Schema = AdventureWorks.Person)]
public partial class Person
{
    [Key]
    public int BusinessEntityID { get; set; }

    [Required]
    [MaxLength(50)]
    public string FirstName { get; set; }

    [Required]
    [MaxLength(50)]
    public string LastName { get; set; }

    public virtual Employee Employee { get; set; } // Reference navigation property.
}

[Table(nameof(Employee), Schema = AdventureWorks.HumanResources)]
public partial class Employee
{
    [Key]
    [ForeignKey(nameof(Person))]
    public int BusinessEntityID { get; set; }
        
    [Required]
    [MaxLength(50)]
    public string JobTitle { get; set; }

    public DateTime HireDate { get; set; }

    public virtual Person Person { get; set; } // Reference navigation property.
}

El atributo [ForeignKey] indica que la propiedad BusinessEntityID de la entidad Empleado es la clave externa para la relación representada por la propiedad de navegación. Aquí Persona se denomina entidad principal y Empleado se denomina entidad dependiente. Sus propiedades de navegación se denominan propiedades de navegación de referencia, porque cada propiedad de navegación puede hacer referencia a una sola entidad.

De uno a muchos

Las tablas Production.ProductCategory y Production.ProductSubcategory tienen una relación de uno a varios, al igual que Production.ProductSubcategory y Production.Product:

Cada fila en la tabla Production.ProductCategory puede hacer referencia a muchas filas en la tabla Production.ProductSubcategory (la categoría puede tener muchas subcategorías), y cada fila en la tabla Production.ProductSubcategory puede hacer referencia a muchas filas en la tabla Production.Product (la subcategoría puede tener muchos productos) :

CREATE TABLE [Production].[ProductSubcategory](
    [ProductSubcategoryID] int IDENTITY(1,1) NOT NULL
        CONSTRAINT [PK_ProductSubcategory_ProductSubcategoryID] PRIMARY KEY CLUSTERED,

    [Name] [dbo].[Name] NOT NULL, -- nvarchar(50).

    [ProductCategoryID] int NOT NULL
        CONSTRAINT [FK_ProductSubcategory_ProductCategory_ProductCategoryID] FOREIGN KEY
        REFERENCES [Production].[ProductCategory] ([ProductCategoryID]),

    /* Other columns. */)
GO

CREATE TABLE [Production].[Product](
    [ProductID] int IDENTITY(1,1) NOT NULL
        CONSTRAINT [PK_Product_ProductID] PRIMARY KEY CLUSTERED,

    [Name] [dbo].[Name] NOT NULL, -- nvarchar(50).

    [ListPrice] money NOT NULL,

    [ProductSubcategoryID] int NULL
        CONSTRAINT [FK_Product_ProductSubcategory_ProductSubcategoryID] FOREIGN KEY
        REFERENCES [Production].[ProductSubcategory] ([ProductSubcategoryID])
    
    /* Other columns. */)
GO

Estas relaciones de uno a varios se pueden representar mediante la propiedad de navegación de tipo ICollection:

public partial class ProductCategory
{
    public virtual ICollection<ProductSubcategory> ProductSubcategories { get; set; } // Collection navigation property.
}

[Table(nameof(ProductSubcategory), Schema = AdventureWorks.Production)]
public partial class ProductSubcategory
{
    [Key]
    [DatabaseGenerated(DatabaseGeneratedOption.Identity)]
    public int ProductSubcategoryID { get; set; }

    [MaxLength(50)]
    [Required]
    public string Name { get; set; }

    public int ProductCategoryID { get; set; }

    public virtual ProductCategory ProductCategory { get; set; } // Reference navigation property.

    public virtual ICollection<Product> Products { get; set; } // Collection navigation property.
}

[Table(nameof(Product), Schema = AdventureWorks.Production)]
public partial class Product
{
    [Key]
    [DatabaseGenerated(DatabaseGeneratedOption.Identity)]
    public int ProductID { get; set; }

    [MaxLength(50)]
    [Required]
    public string Name { get; set; }

    public decimal ListPrice { get; set; }

    public int? ProductSubcategoryID { get; set; }

    public virtual ProductSubcategory ProductSubcategory { get; set; } // Reference navigation property.
}

Observe que la columna ProductSubcategoryID de la tabla Production.Product admite valores NULL, por lo que se asigna a una propiedad System.Nullable. Aquí se omite el atributo [ForeignKey], porque las claves externas de las entidades dependientes son diferentes de sus claves principales, y cada clave externa tiene el mismo nombre que su clave principal, por lo que EF/Core puede descubrirlas automáticamente.

Muchos a muchos

Las tablas Production.Product y Production.ProductPhoto tienen una relación de muchos a muchos.

Esto se implementa mediante 2 relaciones de uno a muchos con otra tabla de unión Production.ProductProductPhoto:

CREATE TABLE [Production].[ProductPhoto](
    [ProductPhotoID] int IDENTITY(1,1) NOT NULL
        CONSTRAINT [PK_ProductPhoto_ProductPhotoID] PRIMARY KEY CLUSTERED,

    [LargePhotoFileName] nvarchar(50) NULL,
    
    [ModifiedDate] datetime NOT NULL 
        CONSTRAINT [DF_ProductPhoto_ModifiedDate] DEFAULT (GETDATE())

    /* Other columns. */)
GO

CREATE TABLE [Production].[ProductProductPhoto](
    [ProductID] int NOT NULL
        CONSTRAINT [FK_ProductProductPhoto_Product_ProductID] FOREIGN KEY
        REFERENCES [Production].[Product] ([ProductID]),

    [ProductPhotoID] int NOT NULL
        CONSTRAINT [FK_ProductProductPhoto_ProductPhoto_ProductPhotoID] FOREIGN KEY
        REFERENCES [Production].[ProductPhoto] ([ProductPhotoID]),

    CONSTRAINT [PK_ProductProductPhoto_ProductID_ProductPhotoID] PRIMARY KEY NONCLUSTERED ([ProductID], [ProductPhotoID])
    
    /* Other columns. */)
GO

Entonces, la relación de muchos a muchos se puede asignar a 2 relaciones de uno a muchos con la unión:

public partial class Product
{
    public virtual ICollection<ProductProductPhoto> ProductProductPhotos { get; set; } // Collection navigation property.
}

[Table(nameof(ProductPhoto), Schema = AdventureWorks.Production)]
public partial class ProductPhoto
{
    [Key]
    [DatabaseGenerated(DatabaseGeneratedOption.Identity)]
    public int ProductPhotoID { get; set; }

    [MaxLength(50)]
    public string LargePhotoFileName { get; set; }

    [ConcurrencyCheck]
    public DateTime ModifiedDate { get; set; }

    public virtual ICollection<ProductProductPhoto> ProductProductPhotos { get; set; } // Collection navigation property.
}

[Table(nameof(ProductProductPhoto), Schema = AdventureWorks.Production)]
public partial class ProductProductPhoto
{
    [Key]
    [Column(Order = 0)]
    public int ProductID { get; set; }

    [Key]
    [Column(Order = 1)]
    public int ProductPhotoID { get; set; }

    public virtual Product Product { get; set; } // Reference navigation property.

    public virtual ProductPhoto ProductPhoto { get; set; } // Reference navigation property.
}

ProductPhoto.ModifiedDate tiene un atributo [ConcurrencyCheck] para la verificación de conflictos de simultaneidad, que se analiza en la parte de simultaneidad. La tabla Production.ProductProductPhoto tiene una clave principal compuesta. Como tabla de unión, cada fila de la tabla tiene una combinación única de ProductID y ProductPhotoID. EF Core requiere información adicional para la clave principal compuesta, que se puede proporcionar como tipo anónimo en OnModelCreating:

public partial class AdventureWorks
{
    private static void MapCompositePrimaryKey(ModelBuilder modelBuilder) // Called by OnModelCreating.
    {
        modelBuilder.Entity<ProductProductPhoto>()
            .HasKey(productProductPhoto => new
            {
                ProductID = productProductPhoto.ProductID,
                ProductPhotoID = productProductPhoto.ProductPhotoID
            });
    }
}

EF Core también requiere información adicional para la relación de muchos a muchos representada por 2 relaciones de uno a muchos, que también se puede proporcionar en OnModelCreating:

public partial class AdventureWorks
{
    private static void MapManyToMany(ModelBuilder modelBuilder) // Called by OnModelCreating.
    {
        modelBuilder.Entity<ProductProductPhoto>()
            .HasOne(productProductPhoto => productProductPhoto.Product)
            .WithMany(product => product.ProductProductPhotos)
            .HasForeignKey(productProductPhoto => productProductPhoto.ProductID);

        modelBuilder.Entity<ProductProductPhoto>()
            .HasOne(productProductPhoto => productProductPhoto.ProductPhoto)
            .WithMany(photo => photo.ProductProductPhotos)
            .HasForeignKey(productProductPhoto => productProductPhoto.ProductPhotoID);
    }
}

Finalmente, las filas de cada tabla anterior se pueden exponer como una fuente de datos IQueryable:

public partial class AdventureWorks
{
    public DbSet<Person> People { get; set; }

    public DbSet<Employee> Employees { get; set; }

    public DbSet<ProductSubcategory> ProductSubcategories { get; set; }

    public DbSet<Product> Products { get; set; }

    public DbSet<ProductPhoto> ProductPhotos { get; set; }
}

Herencia

EF/Core también admite herencia para tipos de entidades.

EF Core admite herencia de tabla por jerarquía (TPH), que también es la estrategia predeterminada de EF. Con TPH, las filas en 1 tabla se asignan a muchas entidades en la jerarquía de herencia, por lo que se necesita una columna discriminadora para identificar la entidad de asignación de cada fila específica. Tome la siguiente tabla Production.TransactionHistory como ejemplo:

CREATE TABLE [Production].[TransactionHistory](
    [TransactionID] int IDENTITY(100000,1) NOT NULL
        CONSTRAINT [PK_TransactionHistory_TransactionID] PRIMARY KEY CLUSTERED,

    [ProductID] int NOT NULL
        CONSTRAINT [FK_TransactionHistory_Product_ProductID] FOREIGN KEY
        REFERENCES [Production].[Product] ([ProductID]),

    [TransactionDate] datetime NOT NULL,

    [TransactionType] nchar(1) NOT NULL
        CONSTRAINT [CK_Product_Style] 
        CHECK (UPPER([TransactionType]) = N'P' OR UPPER([TransactionType]) = N'S' OR UPPER([TransactionType]) = N'W'),

    [Quantity] int NOT NULL,

    [ActualCost] money NOT NULL

    /* Other columns. */);
GO

Su columna TransactionType permite que el valor "P", "S" o "W" indique cada fila que representa una transacción de compra, transacción de venta o transacción de trabajo. Entonces la jerarquía de mapeo puede ser:

[Table(nameof(TransactionHistory), Schema = AdventureWorks.Production)]
public abstract class TransactionHistory
{
    [Key]
    public int TransactionID { get; set; }

    public int ProductID { get; set; }

    public DateTime TransactionDate { get; set; }

    public int Quantity { get; set; }

    public decimal ActualCost { get; set; }
}

public class PurchaseTransactionHistory : TransactionHistory { }

public class SalesTransactionHistory : TransactionHistory { }

public class WorkTransactionHistory : TransactionHistory { }

Luego, el discriminador debe especificarse a través de OnModelCreating. Las API de EF y EF Core son diferentes:

public enum TransactionType { P, S, W }

public partial class AdventureWorks
{
    private static void MapDiscriminator(ModelBuilder modelBuilder) // Called by OnModelCreating.
    {
#if EF
        modelBuilder
            .Entity<TransactionHistory>()
            .Map<PurchaseTransactionHistory>(mapping => mapping.Requires(nameof(TransactionType))
                .HasValue(nameof(TransactionType.P)))
            .Map<SalesTransactionHistory>(mapping => mapping.Requires(nameof(TransactionType))
                .HasValue(nameof(TransactionType.S)))
            .Map<WorkTransactionHistory>(mapping => mapping.Requires(nameof(TransactionType))
                .HasValue(nameof(TransactionType.W)));
#else
        modelBuilder.Entity<TransactionHistory>()
            .HasDiscriminator<string>(nameof(TransactionType))
            .HasValue<PurchaseTransactionHistory>(nameof(TransactionType.P))
            .HasValue<SalesTransactionHistory>(nameof(TransactionType.S))
            .HasValue<WorkTransactionHistory>(nameof(TransactionType.W));
#endif
    }
}

Ahora todas estas entidades pueden exponerse como fuentes de datos:

public partial class AdventureWorks
{
    public DbSet<TransactionHistory> Transactions { get; set; }

    public DbSet<PurchaseTransactionHistory> PurchaseTransactions { get; set; }

    public DbSet<SalesTransactionHistory> SalesTransactions { get; set; }

    public DbSet<WorkTransactionHistory> WorkTransactions { get; set; }
}

Visualizaciones

Una vista también se puede mapear como si fuera una tabla, si la vista tiene una o más columnas que se pueden ver como clave principal. Tome la vista Production.vEmployee como ejemplo:

CREATE VIEW [HumanResources].[vEmployee] 
AS 
SELECT 
    e.[BusinessEntityID],
    p.[FirstName],
    p.[LastName],
    e.[JobTitle]  
    -- Other columns.
FROM [HumanResources].[Employee] e
    INNER JOIN [Person].[Person] p
    ON p.[BusinessEntityID] = e.[BusinessEntityID]
    /* Other tables. */;
GO

BusinessEntityID es único y se puede ver como clave principal. Por lo tanto, se puede asignar a la siguiente entidad:

[Table(nameof(vEmployee), Schema = AdventureWorks.HumanResources)]
public class vEmployee
{
    [Key]
    public int BusinessEntityID { get; set; }

    public string FirstName { get; set; }

    public string LastName { get; set; }

    public string JobTitle { get; set; }
}

Y luego exponer como fuente de datos:

public partial class AdventureWorks
{
    public DbSet<vEmployee> vEmployees { get; set; }
}

Procedimientos y funciones almacenados