Entity Framework/Core e LINQ to Entities (2) Database di modellazione:mappatura relazionale di oggetti

Entity Framework/Core e LINQ to Entities (2) Database di modellazione:mappatura relazionale di oggetti

[LINQ tramite serie C#]

[Serie Entity Framework Core]

[Serie Entity Framework]

Ultima versione di EF Core di questo articolo: https://weblogs.asp.net/dixin/entity-framework-core-and-linq-to-entities-2-modeling-database-object-relational-mapping

Versione EF di questo articolo: https://weblogs.asp.net/dixin/entity-framework-and-linq-to-entities-3-logging

.NET e database SQL e hanno 2 diversi sistemi di tipi di dati. Ad esempio, .NET ha System.Int64 e System.String, mentre il database SQL ha bigint e nvarchar; .NET ha sequenze e oggetti, mentre il database SQL ha tabelle e righe, ecc. La mappatura relazionale degli oggetti è una tecnologia popolare per mappare e convertire tra oggetti dati dell'applicazione e dati relazionali del database. In LINQ to Entities, le query sono basate sulla mappatura relazionale a oggetti.

Rispetto alla generazione di codice da modelli di dati di entità (.edmx), è più intuitivo e trasparente creare codice da zero. Inoltre, per quanto riguarda EF Core non supporta i modelli di dati di entità (.edmx) e supporta solo il codice prima, questo tutorial segue l'approccio del primo codice.

Tipi di dati

EF/Core può mappare la maggior parte dei tipi di dati SQL ai tipi .NET:

Categoria di tipo SQL Tipo SQL Tipo .NET Primativa C#
Numero esatto bit Sistema.Booleano bollo
tinyint Byte di sistema byte
smallint Sistema.Int16 corto
int Sistema.Int32 int
bigint Sistema.Int64 lungo
smallmoney, denaro, decimale, numerico System.Decimal decimale
Numero approssimativo reale Sistema.Singolo flottante
flottante Sistema.Doppio doppio
Stringa di caratteri carattere, varchar, testo Stringa.Sistema stringa
nchar, nvarchar, ntext Stringa.Sistema stringa
Stringa binaria binario, varbinary Byte.Sistema[] byte[]
immagine Byte.Sistema[] byte[]
rowversion (timestamp) Byte.Sistema[] byte[]
Data e ora data System.DateTime
tempo System.TimeSpan
smalldatetime, datetime, datetime2 System.DateTime
datatimeoffset System.DateTimeOffset
Tipo spaziale geografia System.Data.Entity.Spatial.DbGeography*
geometria System.Data.Entity.Spatial.DbGeometry*
Altro id gerarchia Nessuna mappatura o supporto integrato
xml Stringa.Sistema stringa
identificatore univoco System.Guid
sql_variant Nessuna mappatura o supporto integrato

Banca dati

Un database SQL viene mappato su un tipo derivato da DbContext:

public partial class AdventureWorks : DbContext { }

DbContext è fornito come:

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. In genere, un'istanza di database dovrebbe essere costruita ed eliminata per ogni unità di lavoro, una raccolta di operazioni sui dati che dovrebbero avere esito positivo o negativo come unità:

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

In EF/Core, la maggior parte del mapping relazionale a oggetti può essere implementato in modo dichiarativo e il resto del mapping può essere implementato imperativamente sovrascrivendo DbContext.OnModelCreating, che viene chiamato da EF/Core durante l'inizializzazione dei modelli di entità:

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

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

I metodi MapCompositePrimaryKey, MapManyToMany e MapDiscriminator precedenti verranno implementati subito dopo.

Resilienza della connessione e strategia per i tentativi di esecuzione

Come la mappatura del database, AdventureWorks gestisce anche la connessione al database, che può essere iniettata dal costruttore:

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) { }
}

In questo caso, quando la connessione al database non viene fornita al costruttore, viene creata una nuova connessione al database con la stringa di connessione definita in precedenza. Inoltre, per quanto riguarda la connessione tra l'applicazione e il database SQL potrebbe essere interrotta (a causa della rete, ecc.), EF/Core supporta la resilienza della connessione per il database SQL. Ciò è particolarmente utile per il database SQL di Azure distribuito nel cloud anziché nella rete locale. Nell'esempio precedente, EF Core è specificato per riprovare automaticamente fino a 5 volte con un intervallo di tentativi di 30 secondi.

Tabelle

Ci sono decine di tabelle nel database AdventureWorks, ma niente panico, questo tutorial coinvolge solo alcune tabelle e alcune colonne di queste tabelle. In EF/Core, una definizione di tabella può essere mappata a una definizione di tipo di entità, in cui ogni colonna è mappata a una proprietà di entità. Ad esempio, il database AdventureWorks dispone di una tabella Production.ProductCategory, definita come:

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

Questa definizione di tabella può essere mappata a una definizione di entità 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.
}

L'attributo [Table] specifica il nome e lo schema della tabella. [Table] può essere omesso quando il nome della tabella è uguale al nome dell'entità e la tabella è nello schema dbo predefinito. Nella mappatura delle entità tabella:

  • La colonna ProductCategoryID di tipo int è mappata a una proprietà System.Int32 con lo stesso nome. L'attributo [Chiave] indica che è una chiave primaria. EF/Core richiede che una tabella disponga della chiave primaria da mappare. [DatabaseGenerated] indica che è una colonna di identità, con valore generato dal database.
  • La colonna Nome è di tipo dbo.Name. che in realtà è nvarchar(50), quindi è mappato alla proprietà Name di tipo System.String. L'attributo [MaxLength] indica che la lunghezza massima del valore della stringa è 50. [Obbligatorio] indica che non deve essere una stringa nulla o vuota o una stringa di spazi bianchi.
  • Le altre colonne rowguid e ModifiedDate non sono mappate. Non vengono utilizzati in questo tutorial per mantenere semplici gli esempi di codice.

In fase di esecuzione, ogni riga della tabella Production.ProductCategory viene mappata a un'istanza ProductCategory.

Le righe dell'intera tabella possono essere mappate a oggetti in un'origine dati IQueryable, esposta come proprietà del tipo di database. DbSet implementa IQueryable e viene fornito per rappresentare un'origine dati tabella:

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

Relazioni

Nel database SQL, le tabelle possono avere relazioni di chiave esterna, incluse le relazioni uno a uno, uno a molti e molti a molti.

Uno a uno

La tabella Person.Person e la tabella HumanResources.Employee seguenti hanno una relazione uno a uno:

La colonna BusinessEntityID della tabella HumanResources.Employee è una chiave esterna che fa riferimento alla chiave primaria della tabella 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

Quindi ogni riga nella tabella HumanResources.Employee fa riferimento a una riga nella tabella Person.Person (un dipendente deve essere una persona). D'altra parte, ogni riga nella tabella Person.Person può essere referenziata da 0 o 1 riga nella tabella HumanResources.Employee (una persona può essere un dipendente o meno). Questa relazione può essere rappresentata dalla proprietà di navigazione del tipo di entità:

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.
}

L'attributo [ForeignKey] indica che la proprietà BusinessEntityID dell'entità Employee è la chiave esterna per la relazione rappresentata dalla proprietà di navigazione. Qui Persona è chiamata entità primaria e Dipendente è chiamata entità dipendente. Le loro proprietà di navigazione sono chiamate proprietà di navigazione di riferimento, perché ogni proprietà di navigazione può fare riferimento a una singola entità.

Uno a molti

Le tabelle Production.ProductCategory e Production.ProductSubcategory hanno una relazione uno-a-molti, così come Production.ProductSubcategory e Production.Product:

Ogni riga nella tabella Production.ProductCategory può fare riferimento a molte righe nella tabella Production.ProductSubcategory (la categoria può avere molte sottocategorie) e ogni riga nella tabella Production.ProductSubcategory può fare riferimento a molte righe nella tabella Production.Product (la sottocategoria può avere molti prodotti) :

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

Queste relazioni uno-a-molti possono essere rappresentate da proprietà di navigazione di 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.
}

Si noti che la colonna ProductSubcategoryID della tabella Production.Product è nullable, quindi è mappata a una proprietà System.Nullable. Qui l'attributo [ForeignKey] viene omesso, perché le chiavi esterne delle entità dipendenti sono diverse dalle loro chiavi primarie e ciascuna chiave esterna ha lo stesso nome della sua chiave primaria, quindi possono essere rilevate automaticamente da EF/Core.

Molti a molti

Le tabelle Production.Product e Production.ProductPhoto hanno una relazione molti-a-molti.

Ciò è implementato da 2 relazioni uno-a-molti con un'altra tabella di giunzione 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

Quindi la relazione molti-a-molti può essere mappata su 2 relazioni uno-a-molti con la giunzione:

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 ha un attributo [ConcurrencyCheck] per il controllo dei conflitti di concorrenza, che viene discusso nella parte relativa alla concorrenza. La tabella Production.ProductProductPhoto ha una chiave primaria composita. Come tabella di giunzione, ogni riga della tabella ha una combinazione univoca di ProductID e ProductPhotoID. EF Core richiede informazioni aggiuntive per la chiave primaria composita, che può essere fornita come tipo anonimo in 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 richiede anche informazioni aggiuntive per la relazione molti-a-molti rappresentata da 2 relazioni uno-a-molti, che possono essere fornite anche in 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);
    }
}

Infine, le righe di ciascuna tabella sopra possono essere esposte come origine dati 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; }
}

Eredità

EF/Core supporta anche l'ereditarietà per i tipi di entità.

EF Core supporta l'ereditarietà tabella per gerarchia (TPH), che è anche la strategia predefinita di EF. Con TPH, le righe in 1 tabella sono mappate a molte entità nella gerarchia di ereditarietà, quindi è necessaria una colonna discriminatore per identificare l'entità di mappatura di ogni riga specifica. Prendi come esempio la seguente tabella Production.TransactionHistory:

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

La sua colonna TransactionType consente al valore "P", "S" o "W" di indicare ogni riga che rappresenta una transazione di acquisto, transazione di vendita o transazione di lavoro. Quindi la gerarchia di mappatura può essere:

[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 { }

Quindi il discriminatore deve essere specificato tramite OnModelCreating. Le API EF ed EF Core sono diverse:

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
    }
}

Ora queste entità possono essere tutte esposte come origini dati:

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; }
}

Viste

Una vista può anche essere mappata come se fosse una tabella, se la vista ha una o più colonne che possono essere visualizzate come chiave primaria. Prendi come esempio la vista Production.vEmployee:

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

Il BusinessEntityID è univoco e può essere visualizzato come chiave primaria. Quindi può essere mappato alla seguente entità:

[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; }
}

E poi esponi come origine dati:

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

Procedure e funzioni memorizzate