Condividi tramite


Novità di EF Core 7.0

EF Core 7.0 (EF7) è stato rilasciato a novembre 2022.

Suggerimento

È possibile eseguire ed eseguire il debug negli esempi scaricando il codice di esempio da GitHub. Ogni sezione è collegata al codice sorgente specifico di tale sezione.

EF7 è destinato a .NET 6 e quindi può essere usato con .NET 6 (LTS) o .NET 7.

Modello di esempio

Molti degli esempi seguenti usano un modello semplice con blog, post, tag e autori:

public class Blog
{
    public Blog(string name)
    {
        Name = name;
    }

    public int Id { get; private set; }
    public string Name { get; set; }
    public List<Post> Posts { get; } = new();
}

public class Post
{
    public Post(string title, string content, DateTime publishedOn)
    {
        Title = title;
        Content = content;
        PublishedOn = publishedOn;
    }

    public int Id { get; private set; }
    public string Title { get; set; }
    public string Content { get; set; }
    public DateTime PublishedOn { get; set; }
    public Blog Blog { get; set; } = null!;
    public List<Tag> Tags { get; } = new();
    public Author? Author { get; set; }
    public PostMetadata? Metadata { get; set; }
}

public class FeaturedPost : Post
{
    public FeaturedPost(string title, string content, DateTime publishedOn, string promoText)
        : base(title, content, publishedOn)
    {
        PromoText = promoText;
    }

    public string PromoText { get; set; }
}

public class Tag
{
    public Tag(string id, string text)
    {
        Id = id;
        Text = text;
    }

    public string Id { get; private set; }
    public string Text { get; set; }
    public List<Post> Posts { get; } = new();
}

public class Author
{
    public Author(string name)
    {
        Name = name;
    }

    public int Id { get; private set; }
    public string Name { get; set; }
    public ContactDetails Contact { get; set; } = null!;
    public List<Post> Posts { get; } = new();
}

Alcuni esempi usano anche tipi di aggregazione, mappati in modi diversi in esempi diversi. Esiste un tipo di aggregazione per i contatti:

public class ContactDetails
{
    public Address Address { get; set; } = null!;
    public string? Phone { get; set; }
}

public class Address
{
    public Address(string street, string city, string postcode, string country)
    {
        Street = street;
        City = city;
        Postcode = postcode;
        Country = country;
    }

    public string Street { get; set; }
    public string City { get; set; }
    public string Postcode { get; set; }
    public string Country { get; set; }
}

E un secondo tipo di aggregazione per i metadati post:

public class PostMetadata
{
    public PostMetadata(int views)
    {
        Views = views;
    }

    public int Views { get; set; }
    public List<SearchTerm> TopSearches { get; } = new();
    public List<Visits> TopGeographies { get; } = new();
    public List<PostUpdate> Updates { get; } = new();
}

public class SearchTerm
{
    public SearchTerm(string term, int count)
    {
        Term = term;
        Count = count;
    }

    public string Term { get; private set; }
    public int Count { get; private set; }
}

public class Visits
{
    public Visits(double latitude, double longitude, int count)
    {
        Latitude = latitude;
        Longitude = longitude;
        Count = count;
    }

    public double Latitude { get; private set; }
    public double Longitude { get; private set; }
    public int Count { get; private set; }
    public List<string>? Browsers { get; set; }
}

public class PostUpdate
{
    public PostUpdate(IPAddress postedFrom, DateTime updatedOn)
    {
        PostedFrom = postedFrom;
        UpdatedOn = updatedOn;
    }

    public IPAddress PostedFrom { get; private set; }
    public string? UpdatedBy { get; init; }
    public DateTime UpdatedOn { get; private set; }
    public List<Commit> Commits { get; } = new();
}

public class Commit
{
    public Commit(DateTime committedOn, string comment)
    {
        CommittedOn = committedOn;
        Comment = comment;
    }

    public DateTime CommittedOn { get; private set; }
    public string Comment { get; set; }
}

Suggerimento

Il modello di esempio è disponibile in BlogsContext.cs.

Colonne JSON

La maggior parte dei database relazionali supporta colonne che contengono documenti JSON. Il codice JSON in queste colonne può essere sottoposto a drill-into con query. Ciò consente, ad esempio, di filtrare e ordinare in base agli elementi dei documenti, nonché la proiezione di elementi dai documenti nei risultati. Le colonne JSON consentono ai database relazionali di assumere alcune delle caratteristiche dei database di documenti, creando un ibrido utile tra i due.

EF7 contiene il supporto indipendente dal provider per le colonne JSON, con un'implementazione per SQL Server. Questo supporto consente il mapping delle aggregazioni create dai tipi .NET ai documenti JSON. Le normali query LINQ possono essere usate nelle aggregazioni e queste verranno convertite nei costrutti di query appropriati necessari per eseguire il drill-into del codice JSON. EF7 supporta anche l'aggiornamento e il salvataggio delle modifiche ai documenti JSON.

Nota

Il supporto di SQLite per JSON è pianificato per il post EF7. I provider PostgreSQL e Pomelo MySQL contengono già il supporto per le colonne JSON. Microsoft lavorerà con gli autori di questi provider per allineare il supporto JSON in tutti i provider.

Mapping a colonne JSON

In EF Core i tipi di aggregazione vengono definiti usando OwnsOne e OwnsMany. Si consideri ad esempio il tipo di aggregazione del modello di esempio usato per archiviare le informazioni di contatto:

public class ContactDetails
{
    public Address Address { get; set; } = null!;
    public string? Phone { get; set; }
}

public class Address
{
    public Address(string street, string city, string postcode, string country)
    {
        Street = street;
        City = city;
        Postcode = postcode;
        Country = country;
    }

    public string Street { get; set; }
    public string City { get; set; }
    public string Postcode { get; set; }
    public string Country { get; set; }
}

Può quindi essere usato in un tipo di entità "proprietario", ad esempio per archiviare i dettagli di contatto di un autore:

public class Author
{
    public int Id { get; set; }
    public string Name { get; set; }
    public ContactDetails Contact { get; set; }
}

Il tipo di aggregazione è configurato in OnModelCreating usando OwnsOne:

protected override void OnModelCreating(ModelBuilder modelBuilder)
{
    modelBuilder.Entity<Author>().OwnsOne(
        author => author.Contact, ownedNavigationBuilder =>
        {
            ownedNavigationBuilder.OwnsOne(contactDetails => contactDetails.Address);
        });
}

Suggerimento

Il codice illustrato di seguito proviene da JsonColumnsSample.cs.

Per impostazione predefinita, i provider di database relazionali eseguono il mapping dei tipi di aggregazione simili alla stessa tabella del tipo di entità proprietario. Ovvero, ogni proprietà delle ContactDetails classi e Address viene mappata a una colonna nella Authors tabella.

Alcuni autori salvati con i dettagli di contatto avranno un aspetto simile al seguente:

Autori

ID. Nome Contact_Address_Street Contact_Address_City Contact_Address_Postcode Contact_Address_Country Contact_Phone
1 Maddy Montaquila 1 Principale St Camberwick Green CW1 5ZH REGNO UNITO 01632 12345
2 Jeremy Likness 2 Main St Chigley CW1 5ZH REGNO UNITO 01632 12346
3 Daniel Roth 3 Principale St Camberwick Green CW1 5ZH REGNO UNITO 01632 12347
4 Arthur Vickers 15a Main St Chigley CW1 5ZH Regno Unito 01632 22345
5 Brice Lambson 4 Main St Chigley CW1 5ZH REGNO UNITO 01632 12349

Se necessario, è possibile eseguire il mapping di ogni tipo di entità che compongo l'aggregazione alla propria tabella:

protected override void OnModelCreating(ModelBuilder modelBuilder)
{
    modelBuilder.Entity<Author>().OwnsOne(
        author => author.Contact, ownedNavigationBuilder =>
        {
            ownedNavigationBuilder.ToTable("Contacts");
            ownedNavigationBuilder.OwnsOne(
                contactDetails => contactDetails.Address, ownedOwnedNavigationBuilder =>
                {
                    ownedOwnedNavigationBuilder.ToTable("Addresses");
                });
        });
}

Gli stessi dati vengono quindi archiviati in tre tabelle:

Autori

ID. Nome
1 Maddy Montaquila
2 Jeremy Likness
3 Daniel Roth
4 Arthur Vickers
5 Brice Lambson

Contatti

AuthorId Telefono
1 01632 12345
2 01632 12346
3 01632 12347
4 01632 22345
5 01632 12349

Indirizzi

ContactDetailsAuthorId Via City Codice postale Country
1 1 Principale St Camberwick Green CW1 5ZH REGNO UNITO
2 2 Main St Chigley CW1 5ZH Regno Unito
3 3 Principale St Camberwick Green CW1 5ZH Regno Unito
4 15a Main St Chigley CW1 5ZH Regno Unito
5 4 Main St Chigley CW1 5ZH REGNO UNITO

Ora, per la parte interessante. In EF7 il ContactDetails tipo di aggregazione può essere mappato a una colonna JSON. Questa operazione richiede una sola chiamata a ToJson() quando si configura il tipo di aggregazione:

protected override void OnModelCreating(ModelBuilder modelBuilder)
{
    modelBuilder.Entity<Author>().OwnsOne(
        author => author.Contact, ownedNavigationBuilder =>
        {
            ownedNavigationBuilder.ToJson();
            ownedNavigationBuilder.OwnsOne(contactDetails => contactDetails.Address);
        });
}

La Authors tabella conterrà ora una colonna JSON per ContactDetails il popolamento con un documento JSON per ogni autore:

Autori

ID. Nome Contatto
1 Maddy Montaquila {
  "Phone":"01632 12345",
  "Address": {
    "City":"Camberwick Green",
    "Country":"UK",
    "Postcode":"CW1 5ZH",
    "Street":"1 Main St"
  }
}
2 Jeremy Likness {
  "Phone":"01632 12346",
  "Address": {
    "City":"Chigley",
    "Country":"UK",
    "Postcode":"CH1 5ZH",
    "Street":"2 Main St"
  }
}
3 Daniel Roth {
  "Phone":"01632 12347",
  "Address": {
    "City":"Camberwick Green",
    "Country":"UK",
    "Postcode":"CW1 5ZH",
    "Street":"3 Main St"
  }
}
4 Arthur Vickers {
  "Phone":"01632 12348",
  "Address": {
    "City":"Chigley",
    "Country":"UK",
    "Postcode":"CH1 5ZH",
    "Street":"15a Main St"
  }
}
5 Brice Lambson {
  "Phone":"01632 12349",
  "Address": {
    "City":"Chigley",
    "Country":"UK",
    "Postcode":"CH1 5ZH",
    "Street":"4 Main St"
  }
}

Suggerimento

Questo uso di aggregazioni è molto simile al modo in cui i documenti JSON vengono mappati quando si usa il provider EF Core per Azure Cosmos DB. Le colonne JSON offrono le funzionalità dell'uso di EF Core nei database di documenti nei documenti incorporati in un database relazionale.

I documenti JSON illustrati in precedenza sono molto semplici, ma questa funzionalità di mapping può essere usata anche con strutture di documenti più complesse. Si consideri ad esempio un altro tipo di aggregazione del modello di esempio, usato per rappresentare i metadati relativi a un post:

public class PostMetadata
{
    public PostMetadata(int views)
    {
        Views = views;
    }

    public int Views { get; set; }
    public List<SearchTerm> TopSearches { get; } = new();
    public List<Visits> TopGeographies { get; } = new();
    public List<PostUpdate> Updates { get; } = new();
}

public class SearchTerm
{
    public SearchTerm(string term, int count)
    {
        Term = term;
        Count = count;
    }

    public string Term { get; private set; }
    public int Count { get; private set; }
}

public class Visits
{
    public Visits(double latitude, double longitude, int count)
    {
        Latitude = latitude;
        Longitude = longitude;
        Count = count;
    }

    public double Latitude { get; private set; }
    public double Longitude { get; private set; }
    public int Count { get; private set; }
    public List<string>? Browsers { get; set; }
}

public class PostUpdate
{
    public PostUpdate(IPAddress postedFrom, DateTime updatedOn)
    {
        PostedFrom = postedFrom;
        UpdatedOn = updatedOn;
    }

    public IPAddress PostedFrom { get; private set; }
    public string? UpdatedBy { get; init; }
    public DateTime UpdatedOn { get; private set; }
    public List<Commit> Commits { get; } = new();
}

public class Commit
{
    public Commit(DateTime committedOn, string comment)
    {
        CommittedOn = committedOn;
        Comment = comment;
    }

    public DateTime CommittedOn { get; private set; }
    public string Comment { get; set; }
}

Questo tipo di aggregazione contiene diversi tipi e raccolte annidati. Le chiamate a OwnsOne e OwnsMany vengono usate per eseguire il mapping di questo tipo di aggregazione:

modelBuilder.Entity<Post>().OwnsOne(
    post => post.Metadata, ownedNavigationBuilder =>
    {
        ownedNavigationBuilder.ToJson();
        ownedNavigationBuilder.OwnsMany(metadata => metadata.TopSearches);
        ownedNavigationBuilder.OwnsMany(metadata => metadata.TopGeographies);
        ownedNavigationBuilder.OwnsMany(
            metadata => metadata.Updates,
            ownedOwnedNavigationBuilder => ownedOwnedNavigationBuilder.OwnsMany(update => update.Commits));
    });

Suggerimento

ToJson è necessario solo nella radice di aggregazione per eseguire il mapping dell'intera aggregazione a un documento JSON.

Con questo mapping, EF7 può creare ed eseguire query in un documento JSON complesso simile al seguente:

{
  "Views": 5085,
  "TopGeographies": [
    {
      "Browsers": "Firefox, Netscape",
      "Count": 924,
      "Latitude": 110.793,
      "Longitude": 39.2431
    },
    {
      "Browsers": "Firefox, Netscape",
      "Count": 885,
      "Latitude": 133.793,
      "Longitude": 45.2431
    }
  ],
  "TopSearches": [
    {
      "Count": 9359,
      "Term": "Search #1"
    }
  ],
  "Updates": [
    {
      "PostedFrom": "127.0.0.1",
      "UpdatedBy": "Admin",
      "UpdatedOn": "1996-02-17T19:24:29.5429092Z",
      "Commits": []
    },
    {
      "PostedFrom": "127.0.0.1",
      "UpdatedBy": "Admin",
      "UpdatedOn": "2019-11-24T19:24:29.5429093Z",
      "Commits": [
        {
          "Comment": "Commit #1",
          "CommittedOn": "2022-08-21T00:00:00+01:00"
        }
      ]
    },
    {
      "PostedFrom": "127.0.0.1",
      "UpdatedBy": "Admin",
      "UpdatedOn": "1997-05-28T19:24:29.5429097Z",
      "Commits": [
        {
          "Comment": "Commit #1",
          "CommittedOn": "2022-08-21T00:00:00+01:00"
        },
        {
          "Comment": "Commit #2",
          "CommittedOn": "2022-08-21T00:00:00+01:00"
        }
      ]
    }
  ]
}

Nota

Il mapping dei tipi spaziali direttamente a JSON non è ancora supportato. Il documento precedente usa i double valori come soluzione alternativa. Votare per Supportare i tipi spaziali nelle colonne JSON se si tratta di un elemento a cui si è interessati.

Nota

Il mapping di raccolte di tipi primitivi a JSON non è ancora supportato. Il documento precedente usa un convertitore di valori per trasformare la raccolta in una stringa delimitata da virgole. Votare per Json: aggiungere il supporto per la raccolta di tipi primitivi se si tratta di un elemento a cui si è interessati.

Nota

Il mapping dei tipi di proprietà a JSON non è ancora supportato insieme all'ereditarietà TPT o TPC. Votare per supportare le proprietà JSON con mapping di ereditarietà TPT/TPC se si è interessati.

Query in colonne JSON

Le query nelle colonne JSON funzionano esattamente come l'esecuzione di query in qualsiasi altro tipo di aggregazione in EF Core. Vale a dire, basta usare LINQ! Di seguito sono riportati alcuni esempi.

Query per tutti gli autori che risiedono in Chigley:

var authorsInChigley = await context.Authors
    .Where(author => author.Contact.Address.City == "Chigley")
    .ToListAsync();

Questa query genera il codice SQL seguente quando si usa SQL Server:

SELECT [a].[Id], [a].[Name], JSON_QUERY([a].[Contact],'$')
FROM [Authors] AS [a]
WHERE CAST(JSON_VALUE([a].[Contact],'$.Address.City') AS nvarchar(max)) = N'Chigley'

Si noti l'uso di JSON_VALUE per ottenere l'oggetto City dall'interno Address del documento JSON.

Select può essere usato per estrarre e proiettare gli elementi dal documento JSON:

var postcodesInChigley = await context.Authors
    .Where(author => author.Contact.Address.City == "Chigley")
    .Select(author => author.Contact.Address.Postcode)
    .ToListAsync();

Questa query genera il codice SQL seguente:

SELECT CAST(JSON_VALUE([a].[Contact],'$.Address.Postcode') AS nvarchar(max))
FROM [Authors] AS [a]
WHERE CAST(JSON_VALUE([a].[Contact],'$.Address.City') AS nvarchar(max)) = N'Chigley'

Ecco un esempio che esegue un po' di più nel filtro e nella proiezione e ordina anche in base al numero di telefono nel documento JSON:

var orderedAddresses = await context.Authors
    .Where(
        author => (author.Contact.Address.City == "Chigley"
                   && author.Contact.Phone != null)
                  || author.Name.StartsWith("D"))
    .OrderBy(author => author.Contact.Phone)
    .Select(
        author => author.Name + " (" + author.Contact.Address.Street
                  + ", " + author.Contact.Address.City
                  + " " + author.Contact.Address.Postcode + ")")
    .ToListAsync();

Questa query genera il codice SQL seguente:

SELECT (((((([a].[Name] + N' (') + CAST(JSON_VALUE([a].[Contact],'$.Address.Street') AS nvarchar(max))) + N', ') + CAST(JSON_VALUE([a].[Contact],'$.Address.City') AS nvarchar(max))) + N' ') + CAST(JSON_VALUE([a].[Contact],'$.Address.Postcode') AS nvarchar(max))) + N')'
FROM [Authors] AS [a]
WHERE (CAST(JSON_VALUE([a].[Contact],'$.Address.City') AS nvarchar(max)) = N'Chigley' AND CAST(JSON_VALUE([a].[Contact],'$.Phone') AS nvarchar(max)) IS NOT NULL) OR ([a].[Name] LIKE N'D%')
ORDER BY CAST(JSON_VALUE([a].[Contact],'$.Phone') AS nvarchar(max))

Quando il documento JSON contiene raccolte, è possibile proiettarli nei risultati:

var postsWithViews = await context.Posts.Where(post => post.Metadata!.Views > 3000)
    .AsNoTracking()
    .Select(
        post => new
        {
            post.Author!.Name, post.Metadata!.Views, Searches = post.Metadata.TopSearches, Commits = post.Metadata.Updates
        })
    .ToListAsync();

Questa query genera il codice SQL seguente:

SELECT [a].[Name], CAST(JSON_VALUE([p].[Metadata],'$.Views') AS int), JSON_QUERY([p].[Metadata],'$.TopSearches'), [p].[Id], JSON_QUERY([p].[Metadata],'$.Updates')
FROM [Posts] AS [p]
LEFT JOIN [Authors] AS [a] ON [p].[AuthorId] = [a].[Id]
WHERE CAST(JSON_VALUE([p].[Metadata],'$.Views') AS int) > 3000

Nota

Le query più complesse che coinvolgono le raccolte JSON richiedono jsonpath il supporto. Votare per supportare l'esecuzione di query jsonpath se si tratta di un elemento a cui si è interessati.

Suggerimento

Prendere in considerazione la creazione di indici per migliorare le prestazioni delle query nei documenti JSON. Ad esempio, vedere Indicizzare i dati JSON quando si usa SQL Server.

Aggiornamento delle colonne JSON

SaveChanges e SaveChangesAsync funzionano normalmente per apportare aggiornamenti a una colonna JSON. Per modifiche estese, l'intero documento verrà aggiornato. Ad esempio, sostituendo la maggior parte del Contact documento per un autore:

var jeremy = await context.Authors.SingleAsync(author => author.Name.StartsWith("Jeremy"));

jeremy.Contact = new() { Address = new("2 Riverside", "Trimbridge", "TB1 5ZS", "UK"), Phone = "01632 88346" };

await context.SaveChangesAsync();

In questo caso, l'intero nuovo documento viene passato come parametro:

info: 8/30/2022 20:21:24.392 RelationalEventId.CommandExecuted[20101] (Microsoft.EntityFrameworkCore.Database.Command)
      Executed DbCommand (2ms) [Parameters=[@p0='{"Phone":"01632 88346","Address":{"City":"Trimbridge","Country":"UK","Postcode":"TB1 5ZS","Street":"2 Riverside"}}' (Nullable = false) (Size = 114), @p1='2'], CommandType='Text', CommandTimeout='30']

Che viene quindi usato in UPDATE SQL:

UPDATE [Authors] SET [Contact] = @p0
OUTPUT 1
WHERE [Id] = @p1;

Tuttavia, se viene modificato solo un documento secondario, EF Core userà un JSON_MODIFY comando per aggiornare solo il documento secondario. Ad esempio, modificando l'oggetto Address all'interno di un Contact documento:

var brice = await context.Authors.SingleAsync(author => author.Name.StartsWith("Brice"));

brice.Contact.Address = new("4 Riverside", "Trimbridge", "TB1 5ZS", "UK");

await context.SaveChangesAsync();

Genera i parametri seguenti:

info: 10/2/2022 15:51:15.895 RelationalEventId.CommandExecuted[20101] (Microsoft.EntityFrameworkCore.Database.Command)
      Executed DbCommand (2ms) [Parameters=[@p0='{"City":"Trimbridge","Country":"UK","Postcode":"TB1 5ZS","Street":"4 Riverside"}' (Nullable = false) (Size = 80), @p1='5'], CommandType='Text', CommandTimeout='30']

Usato in UPDATE tramite una JSON_MODIFY chiamata:

UPDATE [Authors] SET [Contact] = JSON_MODIFY([Contact], 'strict $.Address', JSON_QUERY(@p0))
OUTPUT 1
WHERE [Id] = @p1;

Infine, se viene modificata solo una singola proprietà, EF Core userà di nuovo un comando "JSON_MODIFY", questa volta per applicare patch solo al valore della proprietà modificata. Ad esempio:

var arthur = await context.Authors.SingleAsync(author => author.Name.StartsWith("Arthur"));

arthur.Contact.Address.Country = "United Kingdom";

await context.SaveChangesAsync();

Genera i parametri seguenti:

info: 10/2/2022 15:54:05.112 RelationalEventId.CommandExecuted[20101] (Microsoft.EntityFrameworkCore.Database.Command)
      Executed DbCommand (2ms) [Parameters=[@p0='["United Kingdom"]' (Nullable = false) (Size = 18), @p1='4'], CommandType='Text', CommandTimeout='30']

Che vengono nuovamente usati con un oggetto JSON_MODIFY:

UPDATE [Authors] SET [Contact] = JSON_MODIFY([Contact], 'strict $.Address.Country', JSON_VALUE(@p0, '$[0]'))
OUTPUT 1
WHERE [Id] = @p1;

ExecuteUpdate e ExecuteDelete (aggiornamenti in blocco)

Per impostazione predefinita, EF Core tiene traccia delle modifiche apportate alle entità e quindi invia aggiornamenti al database quando viene chiamato uno dei SaveChanges metodi. Le modifiche vengono inviate solo per le proprietà e le relazioni effettivamente modificate. Inoltre, le entità rilevate rimangono sincronizzate con le modifiche inviate al database. Questo meccanismo è un modo efficiente e pratico per inviare inserimenti, aggiornamenti ed eliminazioni per utilizzo generico al database. Queste modifiche vengono inoltre raggruppate per ridurre il numero di round trip del database.

Tuttavia, a volte è utile eseguire comandi di aggiornamento o eliminazione nel database senza coinvolgere lo strumento di rilevamento delle modifiche. EF7 consente di eseguire questa operazione con i nuovi ExecuteUpdate metodi e ExecuteDelete . Questi metodi vengono applicati a una query LINQ e aggiorneranno o elimineranno le entità nel database in base ai risultati della query. Molte entità possono essere aggiornate con un singolo comando e le entità non vengono caricate in memoria, il che significa che ciò può comportare aggiornamenti ed eliminazioni più efficienti.

Tenere tuttavia presente che:

  • Le modifiche specifiche da apportare devono essere specificate in modo esplicito; non vengono rilevati automaticamente da EF Core.
  • Tutte le entità rilevate non verranno mantenute sincronizzate.
  • Potrebbe essere necessario inviare comandi aggiuntivi nell'ordine corretto in modo da non violare i vincoli del database. Ad esempio, l'eliminazione di dipendenti prima che un'entità possa essere eliminata.

Tutto ciò significa che i ExecuteUpdate metodi e ExecuteDelete si integrano, anziché sostituire, il meccanismo esistente SaveChanges .

Esempi di base ExecuteDelete

Suggerimento

Il codice illustrato di seguito proviene da ExecuteDeleteSample.cs.

La chiamata ExecuteDelete o su un DbSet elimina immediatamente tutte le entità di tale DbSet ExecuteDeleteAsync entità dal database. Ad esempio, per eliminare tutte le Tag entità:

await context.Tags.ExecuteDeleteAsync();

In questo modo viene eseguito il codice SQL seguente quando si usa SQL Server:

DELETE FROM [t]
FROM [Tags] AS [t]

Più interessante, la query può contenere un filtro. Ad esempio:

await context.Tags.Where(t => t.Text.Contains(".NET")).ExecuteDeleteAsync();

In questo modo viene eseguito il codice SQL seguente:

DELETE FROM [t]
FROM [Tags] AS [t]
WHERE [t].[Text] LIKE N'%.NET%'

La query può anche usare filtri più complessi, inclusi gli spostamenti ad altri tipi. Ad esempio, per eliminare i tag solo dai vecchi post di blog:

await context.Tags.Where(t => t.Posts.All(e => e.PublishedOn.Year < 2022)).ExecuteDeleteAsync();

Che esegue:

DELETE FROM [t]
FROM [Tags] AS [t]
WHERE NOT EXISTS (
    SELECT 1
    FROM [PostTag] AS [p]
    INNER JOIN [Posts] AS [p0] ON [p].[PostsId] = [p0].[Id]
    WHERE [t].[Id] = [p].[TagsId] AND NOT (DATEPART(year, [p0].[PublishedOn]) < 2022))

Esempi di base ExecuteUpdate

Suggerimento

Il codice illustrato di seguito proviene da ExecuteUpdateSample.cs.

ExecuteUpdate e ExecuteUpdateAsync si comportano in modo molto simile ai ExecuteDelete metodi. La differenza principale è che un aggiornamento richiede conoscere le proprietà da aggiornare e come aggiornarle. Questo risultato viene ottenuto usando una o più chiamate a SetProperty. Ad esempio, per aggiornare l'oggetto Name di ogni blog:

await context.Blogs.ExecuteUpdateAsync(
    s => s.SetProperty(b => b.Name, b => b.Name + " *Featured!*"));

Il primo parametro di SetProperty specifica la proprietà da aggiornare; in questo caso, Blog.Name. Il secondo parametro specifica come calcolare il nuovo valore; in questo caso, prendendo il valore esistente e accodando "*Featured!*". Il codice SQL risultante è:

UPDATE [b]
SET [b].[Name] = [b].[Name] + N' *Featured!*'
FROM [Blogs] AS [b]

Come con ExecuteDelete, la query può essere usata per filtrare le entità aggiornate. Inoltre, è possibile usare più chiamate a SetProperty per aggiornare più proprietà nell'entità di destinazione. Ad esempio, per aggiornare e Title Content di tutti i post pubblicati prima del 2022:

await context.Posts
    .Where(p => p.PublishedOn.Year < 2022)
    .ExecuteUpdateAsync(s => s
        .SetProperty(b => b.Title, b => b.Title + " (" + b.PublishedOn.Year + ")")
        .SetProperty(b => b.Content, b => b.Content + " ( This content was published in " + b.PublishedOn.Year + ")"));

In questo caso, SQL generato è un po' più complicato:

UPDATE [p]
SET [p].[Content] = (([p].[Content] + N' ( This content was published in ') + COALESCE(CAST(DATEPART(year, [p].[PublishedOn]) AS nvarchar(max)), N'')) + N')',
    [p].[Title] = (([p].[Title] + N' (') + COALESCE(CAST(DATEPART(year, [p].[PublishedOn]) AS nvarchar(max)), N'')) + N')'
FROM [Posts] AS [p]
WHERE DATEPART(year, [p].[PublishedOn]) < 2022

Infine, di nuovo come con ExecuteDelete, il filtro può fare riferimento ad altre tabelle. Ad esempio, per aggiornare tutti i tag dai vecchi post:

await context.Tags
    .Where(t => t.Posts.All(e => e.PublishedOn.Year < 2022))
    .ExecuteUpdateAsync(s => s.SetProperty(t => t.Text, t => t.Text + " (old)"));

Che genera:

UPDATE [t]
SET [t].[Text] = [t].[Text] + N' (old)'
FROM [Tags] AS [t]
WHERE NOT EXISTS (
    SELECT 1
    FROM [PostTag] AS [p]
    INNER JOIN [Posts] AS [p0] ON [p].[PostsId] = [p0].[Id]
    WHERE [t].[Id] = [p].[TagsId] AND NOT (DATEPART(year, [p0].[PublishedOn]) < 2022))

Per altre informazioni ed esempi di codice su ExecuteUpdate e ExecuteDelete, vedere ExecuteUpdate e ExecuteDelete.

Ereditarietà e più tabelle

ExecuteUpdate e ExecuteDelete possono agire solo su una singola tabella. Ciò ha implicazioni quando si lavora con diverse strategie di mapping di ereditarietà. In genere, non ci sono problemi quando si usa la strategia di mapping TPH, poiché è presente una sola tabella da modificare. Ad esempio, eliminando tutte le FeaturedPost entità:

await context.Set<FeaturedPost>().ExecuteDeleteAsync();

Genera il codice SQL seguente quando si usa il mapping TPH:

DELETE FROM [p]
FROM [Posts] AS [p]
WHERE [p].[Discriminator] = N'FeaturedPost'

In questo caso non sono presenti problemi anche quando si usa la strategia di mapping TPC, poiché sono necessarie solo modifiche a una singola tabella:

DELETE FROM [f]
FROM [FeaturedPosts] AS [f]

Tuttavia, il tentativo di eseguire questa operazione quando si usa la strategia di mapping TPT avrà esito negativo perché richiederebbe l'eliminazione di righe da due tabelle diverse.

L'aggiunta di un filtro alla query spesso indica che l'operazione avrà esito negativo con entrambe le strategie TPC e TPT. Anche in questo caso, perché le righe potrebbero essere necessarie per essere eliminate da più tabelle. Ad esempio, la query seguente:

await context.Posts.Where(p => p.Author!.Name.StartsWith("Arthur")).ExecuteDeleteAsync();

Genera il codice SQL seguente quando si usa TPH:

DELETE FROM [p]
FROM [Posts] AS [p]
LEFT JOIN [Authors] AS [a] ON [p].[AuthorId] = [a].[Id]
WHERE [a].[Name] IS NOT NULL AND ([a].[Name] LIKE N'Arthur%')

Ma ha esito negativo quando si usa TPC o TPT.

Suggerimento

Il problema 10879 tiene traccia dell'aggiunta del supporto per l'invio automatico di più comandi in questi scenari. Votare per questo problema se si vuole vedere implementato.

ExecuteDelete relazioni e

Come accennato in precedenza, potrebbe essere necessario eliminare o aggiornare le entità dipendenti prima che l'entità di una relazione possa essere eliminata. Ad esempio, ogni Post oggetto è dipendente dal relativo oggetto associato Author. Ciò significa che un autore non può essere eliminato se un post vi fa ancora riferimento; in questo modo verrà violato il vincolo di chiave esterna nel database. Ad esempio, provando a eseguire questa operazione:

await context.Authors.ExecuteDeleteAsync();

Verrà generata l'eccezione seguente in SQL Server:

Microsoft.Data.SqlClient.SqlException (0x80131904): l'istruzione DELETE è in conflitto con il vincolo REFERENCE "FK_Posts_Authors_AuthorId". Il conflitto si è verificato nel database "TphBlogsContext", tabella "dbo. Post", colonna 'AuthorId'. L'istruzione è stata interrotta.

Per risolvere questo problema, è prima necessario eliminare i post o eliminare la relazione tra ogni post e il relativo autore impostando la AuthorId proprietà della chiave esterna su Null. Ad esempio, usando l'opzione delete:

await context.Posts.TagWith("Deleting posts...").ExecuteDeleteAsync();
await context.Authors.TagWith("Deleting authors...").ExecuteDeleteAsync();

Suggerimento

TagWith può essere usato per contrassegnare ExecuteDelete o ExecuteUpdate allo stesso modo in cui contrassegna le normali query.

Ciò comporta due comandi separati; il primo a eliminare i dipendenti:

-- Deleting posts...

DELETE FROM [p]
FROM [Posts] AS [p]

E il secondo per eliminare le entità:

-- Deleting authors...

DELETE FROM [a]
FROM [Authors] AS [a]

Importante

Per impostazione predefinita, più ExecuteDelete comandi e ExecuteUpdate non saranno contenuti in una singola transazione. Tuttavia, le API di transazione DbContext possono essere usate normalmente per eseguire il wrapping di questi comandi in una transazione.

Suggerimento

L'invio di questi comandi in un singolo round trip dipende dal problema 10879. Votare per questo problema se si vuole vedere implementato.

La configurazione delle eliminazioni a catena nel database può essere molto utile qui. Nel modello è necessaria la relazione tra Blog e Post , che determina la configurazione di un'eliminazione a catena da parte di EF Core per convenzione. Ciò significa che quando un blog viene eliminato dal database, verranno eliminati anche tutti i post dipendenti. Quindi segue che per eliminare tutti i blog e i post è necessario eliminare solo i blog:

await context.Blogs.ExecuteDeleteAsync();

In questo modo viene restituito il codice SQL seguente:

DELETE FROM [b]
FROM [Blogs] AS [b]

Che, poiché sta eliminando un blog, causerà anche l'eliminazione di tutti i post correlati tramite l'eliminazione a catena configurata.

SaveChanges più veloce

In EF7 le prestazioni di SaveChanges e SaveChangesAsync sono state notevolmente migliorate. In alcuni scenari, il salvataggio delle modifiche è ora fino a quattro volte più veloce rispetto a EF Core 6.0.

La maggior parte di questi miglioramenti deriva da:

  • Esecuzione di un minor numero di round trip nel database
  • Generazione di SQL più veloce

Di seguito sono riportati alcuni esempi di questi miglioramenti.

Nota

Per una descrizione approfondita di queste modifiche, vedere Annuncio di Entity Framework Core 7 Preview 6: Performance Edition nel blog di .NET.

Suggerimento

Il codice illustrato di seguito proviene da SaveChangesPerformanceSample.cs.

Le transazioni non desiderate vengono eliminate

Tutti i database relazionali moderni garantiscono la transazionalità per le singole istruzioni SQL (la maggior parte). Ovvero, l'istruzione non verrà mai completata solo parzialmente, anche se si verifica un errore. EF7 evita l'avvio di una transazione esplicita in questi casi.

Ad esempio, esaminando la registrazione per la chiamata seguente a SaveChanges:

await context.AddAsync(new Blog { Name = "MyBlog" });
await context.SaveChangesAsync();

Mostra che in EF Core 6.0 il comando viene sottoposto a wrapping dai comandi per iniziare e quindi eseguire il INSERT commit di una transazione:

dbug: 9/29/2022 11:43:09.196 RelationalEventId.TransactionStarted[20200] (Microsoft.EntityFrameworkCore.Database.Transaction)
      Began transaction with isolation level 'ReadCommitted'.
info: 9/29/2022 11:43:09.265 RelationalEventId.CommandExecuted[20101] (Microsoft.EntityFrameworkCore.Database.Command)
      Executed DbCommand (27ms) [Parameters=[@p0='MyBlog' (Nullable = false) (Size = 4000)], CommandType='Text', CommandTimeout='30']
      SET NOCOUNT ON;
      INSERT INTO [Blogs] ([Name])
      VALUES (@p0);
      SELECT [Id]
      FROM [Blogs]
      WHERE @@ROWCOUNT = 1 AND [Id] = scope_identity();
dbug: 9/29/2022 11:43:09.297 RelationalEventId.TransactionCommitted[20202] (Microsoft.EntityFrameworkCore.Database.Transaction)
      Committed transaction.

EF7 rileva che la transazione non è necessaria qui e quindi rimuove queste chiamate:

info: 9/29/2022 11:42:34.776 RelationalEventId.CommandExecuted[20101] (Microsoft.EntityFrameworkCore.Database.Command)
      Executed DbCommand (25ms) [Parameters=[@p0='MyBlog' (Nullable = false) (Size = 4000)], CommandType='Text', CommandTimeout='30']
      SET IMPLICIT_TRANSACTIONS OFF;
      SET NOCOUNT ON;
      INSERT INTO [Blogs] ([Name])
      OUTPUT INSERTED.[Id]
      VALUES (@p0);

In questo modo vengono rimossi due round trip del database, che possono fare un'enorme differenza per le prestazioni complessive, soprattutto quando la latenza delle chiamate al database è elevata. Nei sistemi di produzione tipici, il database non si trova nello stesso computer dell'applicazione. Ciò significa che la latenza è spesso relativamente elevata, rendendo questa ottimizzazione particolarmente efficace nei sistemi di produzione reali.

Miglioramento di SQL per l'inserimento di identità semplice

Il caso precedente inserisce una singola riga con una IDENTITY colonna chiave e nessun altro valore generato dal database. EF7 semplifica sql in questo caso usando OUTPUT INSERTED. Sebbene questa semplificazione non sia valida per molti altri casi, è comunque importante migliorare poiché questo tipo di inserimento a riga singola è molto comune in molte applicazioni.

Inserimento di più righe

In EF Core 6.0 l'approccio predefinito per l'inserimento di più righe è stato determinato dalle limitazioni nel supporto di SQL Server per le tabelle con trigger. Volevamo assicurarsi che l'esperienza predefinita funzionasse anche per la minoranza di utenti con trigger nelle tabelle. Ciò significa che non è stato possibile usare una clausola semplice OUTPUT perché, in SQL Server, questo non funziona con i trigger. Al contrario, quando si inseriscono più entità, EF Core 6.0 ha generato alcuni SQL abbastanza convoluti. Ad esempio, questa chiamata a SaveChanges:

for (var i = 0; i < 4; i++)
{
    await context.AddAsync(new Blog { Name = "Foo" + i });
}

await context.SaveChangesAsync();

Vengono restituite le azioni seguenti quando vengono eseguite su SQL Server con EF Core 6.0:

dbug: 9/30/2022 17:19:51.919 RelationalEventId.TransactionStarted[20200] (Microsoft.EntityFrameworkCore.Database.Transaction)
      Began transaction with isolation level 'ReadCommitted'.
info: 9/30/2022 17:19:51.993 RelationalEventId.CommandExecuted[20101] (Microsoft.EntityFrameworkCore.Database.Command)
      Executed DbCommand (27ms) [Parameters=[@p0='Foo0' (Nullable = false) (Size = 4000), @p1='Foo1' (Nullable = false) (Size = 4000), @p2='Foo2' (Nullable = false) (Size = 4000), @p3='Foo3' (Nullable = false) (Size = 4000)], CommandType='Text', CommandTimeout='30']
      SET NOCOUNT ON;
      DECLARE @inserted0 TABLE ([Id] int, [_Position] [int]);
      MERGE [Blogs] USING (
      VALUES (@p0, 0),
      (@p1, 1),
      (@p2, 2),
      (@p3, 3)) AS i ([Name], _Position) ON 1=0
      WHEN NOT MATCHED THEN
      INSERT ([Name])
      VALUES (i.[Name])
      OUTPUT INSERTED.[Id], i._Position
      INTO @inserted0;

      SELECT [i].[Id] FROM @inserted0 i
      ORDER BY [i].[_Position];
dbug: 9/30/2022 17:19:52.023 RelationalEventId.TransactionCommitted[20202] (Microsoft.EntityFrameworkCore.Database.Transaction)
      Committed transaction.

Importante

Anche se questo è complicato, l'invio in batch di più inserimenti come questo è ancora molto più veloce rispetto all'invio di un singolo comando per ogni inserimento.

In EF7 è comunque possibile ottenere questo codice SQL se le tabelle contengono trigger, ma per il caso comune ora si generano comandi molto più efficienti, se ancora piuttosto complessi:

info: 9/30/2022 17:40:37.612 RelationalEventId.CommandExecuted[20101] (Microsoft.EntityFrameworkCore.Database.Command)
      Executed DbCommand (4ms) [Parameters=[@p0='Foo0' (Nullable = false) (Size = 4000), @p1='Foo1' (Nullable = false) (Size = 4000), @p2='Foo2' (Nullable = false) (Size = 4000), @p3='Foo3' (Nullable = false) (Size = 4000)], CommandType='Text', CommandTimeout='30']
      SET IMPLICIT_TRANSACTIONS OFF;
      SET NOCOUNT ON;
      MERGE [Blogs] USING (
      VALUES (@p0, 0),
      (@p1, 1),
      (@p2, 2),
      (@p3, 3)) AS i ([Name], _Position) ON 1=0
      WHEN NOT MATCHED THEN
      INSERT ([Name])
      VALUES (i.[Name])
      OUTPUT INSERTED.[Id], i._Position;

La transazione non è più disponibile, come nel caso di inserimento singolo, perché MERGE è una singola istruzione protetta da una transazione implicita. Inoltre, la tabella temporanea non è più disponibile e la clausola OUTPUT invia ora gli ID generati direttamente al client. Questo può essere quattro volte più veloce rispetto a EF Core 6.0, a seconda di fattori ambientali, ad esempio la latenza tra l'applicazione e il database.

Trigger

Se la tabella include trigger, la chiamata a SaveChanges nel codice precedente genererà un'eccezione:

Eccezione non gestita. Microsoft.EntityFrameworkCore.DbUpdateException:
impossibile salvare le modifiche perché la tabella di destinazione include trigger di database. Configurare di conseguenza il tipo di entità. Per altre informazioni, vedere https://aka.ms/efcore-docs-sqlserver-save-changes-and-triggers .
>--- Microsoft.Data.SqlClient.SqlException (0x80131904):
la tabella di destinazione 'BlogsWithTriggers' dell'istruzione DML non può avere trigger abilitati se l'istruzione contiene una clausola OUTPUT senza clausola INTO.

Il codice seguente può essere usato per informare EF Core che la tabella ha un trigger:

modelBuilder
    .Entity<BlogWithTrigger>()
    .ToTable(tb => tb.HasTrigger("TRG_InsertUpdateBlog"));

EF7 eseguirà quindi il ripristino di EF Core 6.0 SQL quando si inviano comandi di inserimento e aggiornamento per questa tabella.

Per altre informazioni, inclusa una convenzione per configurare automaticamente tutte le tabelle mappate con trigger, vedere Tabelle di SQL Server con trigger ora richiedono una configurazione speciale di EF Core nella documentazione relativa alle modifiche di rilievo di EF7.

Meno round trip per l'inserimento di grafici

Prendere in considerazione l'inserimento di un grafico di entità contenente una nuova entità principale e anche nuove entità dipendenti con chiavi esterne che fanno riferimento alla nuova entità. Ad esempio:

await context.AddAsync(
    new Blog { Name = "MyBlog", Posts = { new() { Title = "My first post" }, new() { Title = "My second post" } } });
await context.SaveChangesAsync();

Se la chiave primaria dell'entità viene generata dal database, il valore da impostare per la chiave esterna nel dipendente non è noto fino a quando non viene inserita l'entità. EF Core genera due round trip per this-one per inserire l'entità e recuperare la nuova chiave primaria e un secondo per inserire i dipendenti con il valore della chiave esterna impostato. Poiché sono disponibili due istruzioni per questa operazione, è necessaria una transazione, vale a dire che sono presenti quattro round trip totali:

dbug: 10/1/2022 13:12:02.517 RelationalEventId.TransactionStarted[20200] (Microsoft.EntityFrameworkCore.Database.Transaction)
      Began transaction with isolation level 'ReadCommitted'.
info: 10/1/2022 13:12:02.517 RelationalEventId.CommandExecuted[20101] (Microsoft.EntityFrameworkCore.Database.Command)
      Executed DbCommand (0ms) [Parameters=[@p0='MyBlog' (Nullable = false) (Size = 4000)], CommandType='Text', CommandTimeout='30']
      SET IMPLICIT_TRANSACTIONS OFF;
      SET NOCOUNT ON;
      INSERT INTO [Blogs] ([Name])
      OUTPUT INSERTED.[Id]
      VALUES (@p0);
info: 10/1/2022 13:12:02.529 RelationalEventId.CommandExecuted[20101] (Microsoft.EntityFrameworkCore.Database.Command)
      Executed DbCommand (5ms) [Parameters=[@p1='6', @p2='My first post' (Nullable = false) (Size = 4000), @p3='6', @p4='My second post' (Nullable = false) (Size = 4000)], CommandType='Text', CommandTimeout='30']
      SET IMPLICIT_TRANSACTIONS OFF;
      SET NOCOUNT ON;
      MERGE [Post] USING (
      VALUES (@p1, @p2, 0),
      (@p3, @p4, 1)) AS i ([BlogId], [Title], _Position) ON 1=0
      WHEN NOT MATCHED THEN
      INSERT ([BlogId], [Title])
      VALUES (i.[BlogId], i.[Title])
      OUTPUT INSERTED.[Id], i._Position;
dbug: 10/1/2022 13:12:02.531 RelationalEventId.TransactionCommitted[20202] (Microsoft.EntityFrameworkCore.Database.Transaction)
      Committed transaction.

In alcuni casi, tuttavia, il valore della chiave primaria è noto prima dell'inserimento dell'entità. Valuta gli ambiti seguenti:

  • Valori chiave non generati automaticamente
  • Valori chiave generati nel client, ad esempio Guid chiavi
  • Valori chiave generati nel server in batch, ad esempio quando si usa un generatore di valori hi-lo

In EF7, questi casi sono ora ottimizzati in un singolo round trip. Ad esempio, nel caso precedente in SQL Server, la Blog.Id chiave primaria può essere configurata per usare la strategia di generazione hi-lo:

modelBuilder.Entity<Blog>().Property(e => e.Id).UseHiLo();
modelBuilder.Entity<Post>().Property(e => e.Id).UseHiLo();

La SaveChanges chiamata precedente è ora ottimizzata per un singolo round trip per gli inserimenti.

dbug: 10/1/2022 21:51:55.805 RelationalEventId.TransactionStarted[20200] (Microsoft.EntityFrameworkCore.Database.Transaction)
      Began transaction with isolation level 'ReadCommitted'.
info: 10/1/2022 21:51:55.806 RelationalEventId.CommandExecuted[20101] (Microsoft.EntityFrameworkCore.Database.Command)
      Executed DbCommand (0ms) [Parameters=[@p0='9', @p1='MyBlog' (Nullable = false) (Size = 4000), @p2='10', @p3='9', @p4='My first post' (Nullable = false) (Size = 4000), @p5='11', @p6='9', @p7='My second post' (Nullable = false) (Size = 4000)], CommandType='Text', CommandTimeout='30']
      SET NOCOUNT ON;
      INSERT INTO [Blogs] ([Id], [Name])
      VALUES (@p0, @p1);
      INSERT INTO [Posts] ([Id], [BlogId], [Title])
      VALUES (@p2, @p3, @p4),
      (@p5, @p6, @p7);
dbug: 10/1/2022 21:51:55.807 RelationalEventId.TransactionCommitted[20202] (Microsoft.EntityFrameworkCore.Database.Transaction)
      Committed transaction.

Si noti che una transazione è ancora necessaria qui. Ciò è dovuto al fatto che gli inserimenti vengono eseguiti in due tabelle separate.

EF7 usa anche un singolo batch in altri casi in cui EF Core 6.0 creerebbe più di uno. Ad esempio, quando si eliminano e si inseriscono righe nella stessa tabella.

Valore di SaveChanges

Come illustrato in alcuni esempi, il salvataggio dei risultati nel database può essere un'attività complessa. Questo è il caso in cui l'uso di qualcosa di simile a EF Core ne mostri davvero il valore. EF Core:

  • Raggruppa più comandi di inserimento, aggiornamento ed eliminazione per ridurre i round trip
  • Indica se è necessaria o meno una transazione esplicita
  • Determina l'ordine di inserimento, aggiornamento ed eliminazione di entità in modo che i vincoli del database non vengano violati
  • Assicura che i valori generati dal database vengano restituiti in modo efficiente e propagati nuovamente nelle entità
  • Imposta automaticamente i valori di chiave esterna usando i valori generati per le chiavi primarie
  • Rilevare i conflitti di concorrenza

Inoltre, diversi sistemi di database richiedono SQL diverso per molti di questi casi. Il provider di database EF Core funziona con EF Core per garantire l'invio di comandi corretti ed efficienti per ogni caso.

Mapping di ereditarietà di tipo tabella per concreto (TPC)

Per impostazione predefinita, EF Core esegue il mapping di una gerarchia di ereditarietà dei tipi .NET a una singola tabella di database. Questa operazione è nota come strategia di mapping TPH (Table-Per-Hierarchy). EF Core 5.0 ha introdotto la strategia di tabella per tipo (TPT), che supporta il mapping di ogni tipo .NET a una tabella di database diversa. EF7 introduce la strategia TPC (table-per-concrete-type). TPC esegue anche il mapping dei tipi .NET a tabelle diverse, ma in modo da risolvere alcuni problemi di prestazioni comuni con la strategia TPT.

Suggerimento

Il codice illustrato di seguito proviene da TpcInheritanceSample.cs.

Suggerimento

Il team ef ha dimostrato e parlato approfonditamente del mapping TPC in un episodio di .NET Data Community Standup. Come per tutti gli episodi community standup, è possibile guardare l'episodio TPC ora su YouTube.

Schema del database TPC

La strategia TPC è simile alla strategia TPT, ad eccezione del fatto che viene creata una tabella diversa per ogni tipo concreto nella gerarchia, ma le tabelle non vengono create per i tipi astratti, quindi il nome "table-per-concrete-type". Come per TPT, la tabella stessa indica il tipo dell'oggetto salvato. Tuttavia, a differenza del mapping TPT, ogni tabella contiene colonne per ogni proprietà nel tipo concreto e nei relativi tipi di base. Gli schemi del database TPC vengono denormalizzati.

Si consideri ad esempio il mapping di questa gerarchia:

public abstract class Animal
{
    protected Animal(string name)
    {
        Name = name;
    }

    public int Id { get; set; }
    public string Name { get; set; }
    public abstract string Species { get; }

    public Food? Food { get; set; }
}

public abstract class Pet : Animal
{
    protected Pet(string name)
        : base(name)
    {
    }

    public string? Vet { get; set; }

    public ICollection<Human> Humans { get; } = new List<Human>();
}

public class FarmAnimal : Animal
{
    public FarmAnimal(string name, string species)
        : base(name)
    {
        Species = species;
    }

    public override string Species { get; }

    [Precision(18, 2)]
    public decimal Value { get; set; }

    public override string ToString()
        => $"Farm animal '{Name}' ({Species}/{Id}) worth {Value:C} eats {Food?.ToString() ?? "<Unknown>"}";
}

public class Cat : Pet
{
    public Cat(string name, string educationLevel)
        : base(name)
    {
        EducationLevel = educationLevel;
    }

    public string EducationLevel { get; set; }
    public override string Species => "Felis catus";

    public override string ToString()
        => $"Cat '{Name}' ({Species}/{Id}) with education '{EducationLevel}' eats {Food?.ToString() ?? "<Unknown>"}";
}

public class Dog : Pet
{
    public Dog(string name, string favoriteToy)
        : base(name)
    {
        FavoriteToy = favoriteToy;
    }

    public string FavoriteToy { get; set; }
    public override string Species => "Canis familiaris";

    public override string ToString()
        => $"Dog '{Name}' ({Species}/{Id}) with favorite toy '{FavoriteToy}' eats {Food?.ToString() ?? "<Unknown>"}";
}

public class Human : Animal
{
    public Human(string name)
        : base(name)
    {
    }

    public override string Species => "Homo sapiens";

    public Animal? FavoriteAnimal { get; set; }
    public ICollection<Pet> Pets { get; } = new List<Pet>();

    public override string ToString()
        => $"Human '{Name}' ({Species}/{Id}) with favorite animal '{FavoriteAnimal?.Name ?? "<Unknown>"}'" +
           $" eats {Food?.ToString() ?? "<Unknown>"}";
}

Quando si usa SQL Server, le tabelle create per questa gerarchia sono:

CREATE TABLE [Cats] (
    [Id] int NOT NULL DEFAULT (NEXT VALUE FOR [AnimalSequence]),
    [Name] nvarchar(max) NOT NULL,
    [FoodId] uniqueidentifier NULL,
    [Vet] nvarchar(max) NULL,
    [EducationLevel] nvarchar(max) NOT NULL,
    CONSTRAINT [PK_Cats] PRIMARY KEY ([Id]));

CREATE TABLE [Dogs] (
    [Id] int NOT NULL DEFAULT (NEXT VALUE FOR [AnimalSequence]),
    [Name] nvarchar(max) NOT NULL,
    [FoodId] uniqueidentifier NULL,
    [Vet] nvarchar(max) NULL,
    [FavoriteToy] nvarchar(max) NOT NULL,
    CONSTRAINT [PK_Dogs] PRIMARY KEY ([Id]));

CREATE TABLE [FarmAnimals] (
    [Id] int NOT NULL DEFAULT (NEXT VALUE FOR [AnimalSequence]),
    [Name] nvarchar(max) NOT NULL,
    [FoodId] uniqueidentifier NULL,
    [Value] decimal(18,2) NOT NULL,
    [Species] nvarchar(max) NOT NULL,
    CONSTRAINT [PK_FarmAnimals] PRIMARY KEY ([Id]));

CREATE TABLE [Humans] (
    [Id] int NOT NULL DEFAULT (NEXT VALUE FOR [AnimalSequence]),
    [Name] nvarchar(max) NOT NULL,
    [FoodId] uniqueidentifier NULL,
    [FavoriteAnimalId] int NULL,
    CONSTRAINT [PK_Humans] PRIMARY KEY ([Id]));

Si noti che:

  • Non sono presenti tabelle per i Animal tipi o Pet , poiché si trovano abstract nel modello a oggetti. Tenere presente che C# non consente istanze di tipi astratti e non esiste quindi alcuna situazione in cui un'istanza di tipo astratta verrà salvata nel database.

  • Il mapping delle proprietà nei tipi di base viene ripetuto per ogni tipo concreto. Ad esempio, ogni tabella ha una Name colonna e sia Cats che Dogs hanno una Vet colonna.

  • Il salvataggio di alcuni dati in questo database comporta quanto segue:

Tavolo gatti

ID. Nome FoodId Veterinario EducationLevel
1 Alice 99ca3e98-b26d-4a0c-d4ae-08da7aca624f Pengelly MBA
2 Mac 99ca3e98-b26d-4a0c-d4ae-08da7aca624f Pengelly Scuola materna
8 Baxter 5dc5019e-6f72-454b-d4b0-08da7aca624f Bothell Pet Hospital Bsc

Tabella Cani

ID. Nome FoodId Veterinario FavoriteToy
3 Avviso popup 011aaf6f-d588-4fad-d4ac-08da7aca624f Pengelly Signor Squirrel

Tabella FarmAnimals

ID. Nome FoodId Valore Specie
4 Clyde 1d495075-f527-4498-d4af-08da7aca624f 100.00 Equus africanus asinus

Tabella Degli esseri umani

ID. Nome FoodId FavoriteAnimalId
5 Wendy 5418fd81-7660-432f-d4b1-08da7aca624f 2
6 Arthur 59b495d4-0414-46bf-d4ad-08da7aca624f 1
9 Katie Null 8

Si noti che, a differenza del mapping TPT, tutte le informazioni per un singolo oggetto sono contenute in una singola tabella. A differenza del mapping TPH, inoltre, non esiste alcuna combinazione di colonna e riga in qualsiasi tabella in cui non viene mai usato dal modello. Di seguito verrà illustrato come queste caratteristiche possono essere importanti per le query e l'archiviazione.

Configurazione dell'ereditarietà TPC

Tutti i tipi in una gerarchia di ereditarietà devono essere inclusi in modo esplicito nel modello quando si esegue il mapping della gerarchia con EF Core. A tale scopo, è possibile creare DbSet proprietà per DbContext ogni tipo:

public DbSet<Animal> Animals => Set<Animal>();
public DbSet<Pet> Pets => Set<Pet>();
public DbSet<FarmAnimal> FarmAnimals => Set<FarmAnimal>();
public DbSet<Cat> Cats => Set<Cat>();
public DbSet<Dog> Dogs => Set<Dog>();
public DbSet<Human> Humans => Set<Human>();

In alternativa, usando il Entity metodo in OnModelCreating:

modelBuilder.Entity<Animal>();
modelBuilder.Entity<Pet>();
modelBuilder.Entity<Cat>();
modelBuilder.Entity<Dog>();
modelBuilder.Entity<FarmAnimal>();
modelBuilder.Entity<Human>();

Importante

Questo comportamento è diverso dal comportamento di EF6 legacy, in cui i tipi derivati di tipi di base mappati verrebbero individuati automaticamente se fossero contenuti nello stesso assembly.

Non è necessario eseguire altre operazioni per eseguire il mapping della gerarchia come TPH, perché è la strategia predefinita. Tuttavia, a partire da EF7, TPH può essere reso esplicito chiamando UseTphMappingStrategy sul tipo di base della gerarchia:

modelBuilder.Entity<Animal>().UseTphMappingStrategy();

Per usare invece TPT, modificare questo valore in UseTptMappingStrategy:

modelBuilder.Entity<Animal>().UseTptMappingStrategy();

Analogamente, UseTpcMappingStrategy viene usato per configurare il TPC:

modelBuilder.Entity<Animal>().UseTpcMappingStrategy();

In ogni caso, il nome della tabella usato per ogni tipo viene ricavato dal nome della DbSet proprietà in DbContextoppure può essere configurato usando il ToTable metodo builder o l'attributo [Table] .

Prestazioni delle query TPC

Per le query, la strategia TPC è un miglioramento rispetto al TPT perché garantisce che le informazioni per una determinata istanza di entità vengano sempre archiviate in una singola tabella. Ciò significa che la strategia TPC può essere utile quando la gerarchia mappata è grande e ha molti tipi concreti (in genere foglia), ognuno con un numero elevato di proprietà e dove nella maggior parte delle query vengono usati solo un piccolo subset di tipi.

Il codice SQL generato per tre semplici query LINQ può essere usato per osservare dove TPC funziona bene rispetto a TPH e TPT. Queste query sono:

  1. Query che restituisce entità di tutti i tipi nella gerarchia:

    context.Animals.ToList();
    
  2. Query che restituisce entità da un subset di tipi nella gerarchia:

    context.Pets.ToList();
    
  3. Query che restituisce solo entità da un singolo tipo foglia nella gerarchia:

    context.Cats.ToList();
    

Query TPH

Quando si usa TPH, tutte e tre le query eseguono query su una singola tabella, ma con filtri diversi sulla colonna discriminatoria:

  1. TPH SQL che restituisce entità di tutti i tipi nella gerarchia:

    SELECT [a].[Id], [a].[Discriminator], [a].[FoodId], [a].[Name], [a].[Species], [a].[Value], [a].[FavoriteAnimalId], [a].[Vet], [a].[EducationLevel], [a].[FavoriteToy]
    FROM [Animals] AS [a]
    
  2. TPH SQL che restituisce entità da un subset di tipi nella gerarchia:

    SELECT [a].[Id], [a].[Discriminator], [a].[FoodId], [a].[Name], [a].[Vet], [a].[EducationLevel], [a].[FavoriteToy]
    FROM [Animals] AS [a]
    WHERE [a].[Discriminator] IN (N'Cat', N'Dog')
    
  3. TPH SQL che restituisce solo entità da un singolo tipo foglia nella gerarchia:

    SELECT [a].[Id], [a].[Discriminator], [a].[FoodId], [a].[Name], [a].[Vet], [a].[EducationLevel]
    FROM [Animals] AS [a]
    WHERE [a].[Discriminator] = N'Cat'
    

Tutte queste query devono essere eseguite correttamente, soprattutto con un indice di database appropriato nella colonna discriminatoria.

Query TPT

Quando si usa TPT, tutte queste query richiedono l'unione di più tabelle, poiché i dati per un determinato tipo concreto vengono suddivisi in più tabelle:

  1. TPT SQL che restituisce entità di tutti i tipi nella gerarchia:

    SELECT [a].[Id], [a].[FoodId], [a].[Name], [f].[Species], [f].[Value], [h].[FavoriteAnimalId], [p].[Vet], [c].[EducationLevel], [d].[FavoriteToy], CASE
        WHEN [d].[Id] IS NOT NULL THEN N'Dog'
        WHEN [c].[Id] IS NOT NULL THEN N'Cat'
        WHEN [h].[Id] IS NOT NULL THEN N'Human'
        WHEN [f].[Id] IS NOT NULL THEN N'FarmAnimal'
    END AS [Discriminator]
    FROM [Animals] AS [a]
    LEFT JOIN [FarmAnimals] AS [f] ON [a].[Id] = [f].[Id]
    LEFT JOIN [Humans] AS [h] ON [a].[Id] = [h].[Id]
    LEFT JOIN [Pets] AS [p] ON [a].[Id] = [p].[Id]
    LEFT JOIN [Cats] AS [c] ON [a].[Id] = [c].[Id]
    LEFT JOIN [Dogs] AS [d] ON [a].[Id] = [d].[Id]
    
  2. TPT SQL che restituisce entità da un subset di tipi nella gerarchia:

    SELECT [a].[Id], [a].[FoodId], [a].[Name], [p].[Vet], [c].[EducationLevel], [d].[FavoriteToy], CASE
        WHEN [d].[Id] IS NOT NULL THEN N'Dog'
        WHEN [c].[Id] IS NOT NULL THEN N'Cat'
    END AS [Discriminator]
    FROM [Animals] AS [a]
    INNER JOIN [Pets] AS [p] ON [a].[Id] = [p].[Id]
    LEFT JOIN [Cats] AS [c] ON [a].[Id] = [c].[Id]
    LEFT JOIN [Dogs] AS [d] ON [a].[Id] = [d].[Id]
    
  3. TPT SQL che restituisce solo entità da un singolo tipo foglia nella gerarchia:

    SELECT [a].[Id], [a].[FoodId], [a].[Name], [p].[Vet], [c].[EducationLevel]
    FROM [Animals] AS [a]
    INNER JOIN [Pets] AS [p] ON [a].[Id] = [p].[Id]
    INNER JOIN [Cats] AS [c] ON [a].[Id] = [c].[Id]
    

Nota

EF Core usa la "sintesi discriminatoria" per determinare la tabella da cui provengono i dati e quindi il tipo corretto da usare. Ciò funziona perché LEFT JOIN restituisce i valori Null per la colonna ID dipendente (le "tabelle secondarie") che non sono il tipo corretto. Quindi, per un cane, [d].[Id] sarà diverso da null e tutti gli altri ID (concreti) saranno null.

Tutte queste query possono subire problemi di prestazioni a causa dei join di tabella. Questo è il motivo per cui TPT non è mai una scelta ottimale per le prestazioni delle query.

Query TPC

Il TPC migliora il TPT per tutte queste query perché il numero di tabelle su cui eseguire query è ridotto. Inoltre, i risultati di ogni tabella vengono combinati usando UNION ALL, che può essere notevolmente più veloce rispetto a un join di tabella, poiché non è necessario eseguire alcuna corrispondenza tra righe o deduplicazione di righe.

  1. TPC SQL che restituisce entità di tutti i tipi nella gerarchia:

    SELECT [f].[Id], [f].[FoodId], [f].[Name], [f].[Species], [f].[Value], NULL AS [FavoriteAnimalId], NULL AS [Vet], NULL AS [EducationLevel], NULL AS [FavoriteToy], N'FarmAnimal' AS [Discriminator]
    FROM [FarmAnimals] AS [f]
    UNION ALL
    SELECT [h].[Id], [h].[FoodId], [h].[Name], NULL AS [Species], NULL AS [Value], [h].[FavoriteAnimalId], NULL AS [Vet], NULL AS [EducationLevel], NULL AS [FavoriteToy], N'Human' AS [Discriminator]
    FROM [Humans] AS [h]
    UNION ALL
    SELECT [c].[Id], [c].[FoodId], [c].[Name], NULL AS [Species], NULL AS [Value], NULL AS [FavoriteAnimalId], [c].[Vet], [c].[EducationLevel], NULL AS [FavoriteToy], N'Cat' AS [Discriminator]
    FROM [Cats] AS [c]
    UNION ALL
    SELECT [d].[Id], [d].[FoodId], [d].[Name], NULL AS [Species], NULL AS [Value], NULL AS [FavoriteAnimalId], [d].[Vet], NULL AS [EducationLevel], [d].[FavoriteToy], N'Dog' AS [Discriminator]
    FROM [Dogs] AS [d]
    
  2. TPC SQL che restituisce entità da un subset di tipi nella gerarchia:

    SELECT [c].[Id], [c].[FoodId], [c].[Name], [c].[Vet], [c].[EducationLevel], NULL AS [FavoriteToy], N'Cat' AS [Discriminator]
    FROM [Cats] AS [c]
    UNION ALL
    SELECT [d].[Id], [d].[FoodId], [d].[Name], [d].[Vet], NULL AS [EducationLevel], [d].[FavoriteToy], N'Dog' AS [Discriminator]
    FROM [Dogs] AS [d]
    
  3. TPC SQL che restituisce solo entità da un singolo tipo foglia nella gerarchia:

    SELECT [c].[Id], [c].[FoodId], [c].[Name], [c].[Vet], [c].[EducationLevel]
    FROM [Cats] AS [c]
    

Anche se TPC è migliore di TPT per tutte queste query, le query TPH sono ancora migliori quando restituiscono istanze di più tipi. Questo è uno dei motivi per cui TPH è la strategia predefinita usata da EF Core.

Come illustrato in SQL per la query n. 3, il TPC è davvero eccellente quando si eseguono query per le entità di un singolo tipo foglia. La query usa solo una singola tabella e non richiede filtri.

Inserimenti e aggiornamenti TPC

Il TPC offre anche prestazioni elevate durante il salvataggio di una nuova entità, poiché ciò richiede l'inserimento di una sola riga in una singola tabella. Questo vale anche per il TPH. Con TPT, le righe devono essere inserite in molte tabelle, con prestazioni meno buone.

Lo stesso vale spesso per gli aggiornamenti, anche se in questo caso tutte le colonne aggiornate si trovano nella stessa tabella, anche per TPT, la differenza potrebbe non essere significativa.

Considerazioni sullo spazio

Sia TPT che TPC possono usare meno spazio di archiviazione rispetto al TPH quando sono presenti molti sottotipi con molte proprietà che spesso non vengono usate. Ciò è dovuto al fatto che ogni riga della tabella TPH deve archiviare un oggetto NULL per ognuna di queste proprietà inutilizzate. In pratica, questo è raramente un problema, ma potrebbe essere opportuno considerare quando si archiviano grandi quantità di dati con queste caratteristiche.

Suggerimento

Se il sistema di database lo supporta (e.g. SQL Server), prendere in considerazione l'uso di "colonne di tipo sparse" per le colonne TPH che verranno popolate raramente.

Generazione della chiave

La strategia di mapping dell'ereditarietà scelta ha conseguenze sulla modalità di generazione e gestione dei valori di chiave primaria. Le chiavi in TPH sono facili poiché ogni istanza di entità è rappresentata da una singola riga in una singola tabella. È possibile usare qualsiasi tipo di generazione di valori chiave e non sono necessari vincoli aggiuntivi.

Per la strategia TPT, è sempre presente una riga nella tabella mappata al tipo di base della gerarchia. Qualsiasi tipo di generazione di chiavi può essere usato in questa riga e le chiavi per altre tabelle sono collegate a questa tabella usando vincoli di chiave esterna.

Le cose diventano un po' più complicate per TPC. Prima di tutto, è importante comprendere che EF Core richiede che tutte le entità in una gerarchia abbiano un valore di chiave univoco, anche se le entità hanno tipi diversi. Pertanto, usando il modello di esempio, un cane non può avere lo stesso valore di chiave ID di un gatto. In secondo luogo, a differenza di TPT, non esiste una tabella comune che può fungere da singola posizione in cui i valori chiave risiedono e possono essere generati. Ciò significa che non è possibile utilizzare una colonna semplice Identity .

Per i database che supportano le sequenze, è possibile generare valori di chiave usando una singola sequenza a cui viene fatto riferimento nel vincolo predefinito per ogni tabella. Questa è la strategia usata nelle tabelle TPC illustrate in precedenza, in cui ogni tabella presenta quanto segue:

[Id] int NOT NULL DEFAULT (NEXT VALUE FOR [AnimalSequence])

AnimalSequence è una sequenza di database creata da EF Core. Questa strategia viene usata per impostazione predefinita per le gerarchie TPC quando si usa il provider di database EF Core per SQL Server. I provider di database per altri database che supportano le sequenze devono avere un valore predefinito simile. Altre strategie di generazione chiave che usano sequenze, ad esempio i modelli Hi-Lo, possono essere usate anche con TPC.

Anche se le colonne Identity standard non funzionano con TPC, è possibile usare le colonne Identity se ogni tabella è configurata con un valore di inizializzazione appropriato e un incremento in modo che i valori generati per ogni tabella non siano mai in conflitto. Ad esempio:

modelBuilder.Entity<Cat>().ToTable("Cats", tb => tb.Property(e => e.Id).UseIdentityColumn(1, 4));
modelBuilder.Entity<Dog>().ToTable("Dogs", tb => tb.Property(e => e.Id).UseIdentityColumn(2, 4));
modelBuilder.Entity<FarmAnimal>().ToTable("FarmAnimals", tb => tb.Property(e => e.Id).UseIdentityColumn(3, 4));
modelBuilder.Entity<Human>().ToTable("Humans", tb => tb.Property(e => e.Id).UseIdentityColumn(4, 4));

SQLite non supporta sequenze o inizializzazione/incremento dell'identità e pertanto la generazione di valori di chiave integer non è supportata quando si usa SQLite con la strategia TPC. Tuttavia, la generazione lato client o chiavi univoche globali, ad esempio chiavi GUID, sono supportate in qualsiasi database, incluso SQLite.

Vincoli di chiavi esterne

La strategia di mapping TPC crea uno schema SQL denormalizzato. Questo è un motivo per cui alcuni puristi di database sono contrari. Si consideri ad esempio la colonna FavoriteAnimalIdchiave esterna . Il valore in questa colonna deve corrispondere al valore della chiave primaria di alcuni animali. Questa operazione può essere applicata nel database con un vincolo FK semplice quando si usa TPH o TPT. Ad esempio:

CONSTRAINT [FK_Animals_Animals_FavoriteAnimalId] FOREIGN KEY ([FavoriteAnimalId]) REFERENCES [Animals] ([Id])

Tuttavia, quando si usa il TPC, la chiave primaria per un animale viene archiviata nella tabella per il tipo concreto di tale animale. Ad esempio, la Cats.Id chiave primaria di un gatto viene archiviata nella colonna, mentre la Dogs.Id chiave primaria di un cane viene archiviata nella colonna e così via. Ciò significa che non è possibile creare un vincolo FK per questa relazione.

In pratica, questo non è un problema, purché l'applicazione non tenti di inserire dati non validi. Ad esempio, se tutti i dati vengono inseriti da EF Core e usano gli spostamenti per correlare le entità, è garantito che la colonna FK conterrà sempre un valore PK valido.

Riepilogo e indicazioni

In sintesi, TPC è una buona strategia di mapping da usare quando il codice eseguirà principalmente query per le entità di un singolo tipo foglia. Ciò è dovuto al fatto che i requisiti di archiviazione sono più piccoli e non è presente alcuna colonna discriminatoria che potrebbe richiedere un indice. Anche gli inserimenti e gli aggiornamenti sono efficienti.

Detto questo, TPH è in genere corretto per la maggior parte delle applicazioni ed è un buon valore predefinito per un'ampia gamma di scenari, quindi non aggiungere la complessità del TPC se non è necessario. In particolare, se il codice eseguirà principalmente query per entità di molti tipi, ad esempio la scrittura di query sul tipo di base, è consigliabile usare TPH su TPC.

Usare TPT solo se vincolato a farlo da fattori esterni.

Modelli di reverse engineering personalizzati

È ora possibile personalizzare il codice scaffolding quando si esegue il reverse engineering di un modello EF da un database. Per iniziare, aggiungere i modelli predefiniti al progetto:

dotnet new install Microsoft.EntityFrameworkCore.Templates
dotnet new ef-templates

I modelli possono quindi essere personalizzati e verranno usati automaticamente da dotnet ef dbcontext scaffold e Scaffold-DbContext.

Per altri dettagli, vedere Modelli di reverse engineering personalizzati.

Suggerimento

Il team ef ha dimostrato e parlato in modo approfondito dei modelli di reverse engineering in un episodio di .NET Data Community Standup. Come per tutti gli episodi community standup, puoi guardare l'episodio dei modelli T4 ora su YouTube.

Convenzioni di compilazione dei modelli

EF Core usa un "modello" di metadati per descrivere il mapping dei tipi di entità dell'applicazione al database sottostante. Questo modello viene compilato usando un set di circa 60 "convenzioni". Il modello compilato dalle convenzioni può quindi essere personalizzato usando gli attributi di mapping (nota come "annotazioni dei dati") e/o chiamate all'API DbModelBuilder in OnModelCreating.

A partire da EF7, le applicazioni possono ora rimuovere o sostituire una di queste convenzioni, nonché aggiungere nuove convenzioni. Le convenzioni di compilazione dei modelli sono un modo efficace per controllare la configurazione del modello, ma può essere complesso e difficile da ottenere correttamente. In molti casi, è possibile usare la configurazione del modello pre-convenzione esistente per specificare facilmente una configurazione comune per proprietà e tipi.

Le modifiche alle convenzioni utilizzate da un DbContext oggetto vengono apportate eseguendo l'override del DbContext.ConfigureConventions metodo . Ad esempio:

protected override void ConfigureConventions(ModelConfigurationBuilder configurationBuilder)
{
    configurationBuilder.Conventions.Remove(typeof(ForeignKeyIndexConvention));
}

Suggerimento

Per trovare tutte le convenzioni di compilazione dei modelli predefinite, cercare ogni classe che implementa l'interfaccia IConvention .

Suggerimento

Il codice illustrato di seguito proviene da ModelBuildingConventionsSample.cs.

Rimozione di una convenzione esistente

A volte una delle convenzioni predefinite potrebbe non essere appropriata per l'applicazione, nel qual caso può essere rimossa.

Esempio: Non creare indici per le colonne chiave esterna

In genere è opportuno creare indici per le colonne di chiave esterna (FK) e quindi esiste una convenzione predefinita per questo: ForeignKeyIndexConvention. Esaminando la vista di debug del modello per un Post tipo di entità con relazioni con Blog e Author, è possibile vedere che vengono creati due indici, uno per la BlogId chiave FK e l'altro per FKAuthorId.

  EntityType: Post
    Properties:
      Id (int) Required PK AfterSave:Throw ValueGenerated.OnAdd
      AuthorId (no field, int?) Shadow FK Index
      BlogId (no field, int) Shadow Required FK Index
    Navigations:
      Author (Author) ToPrincipal Author Inverse: Posts
      Blog (Blog) ToPrincipal Blog Inverse: Posts
    Keys:
      Id PK
    Foreign keys:
      Post {'AuthorId'} -> Author {'Id'} ToDependent: Posts ToPrincipal: Author ClientSetNull
      Post {'BlogId'} -> Blog {'Id'} ToDependent: Posts ToPrincipal: Blog Cascade
    Indexes:
      AuthorId
      BlogId

Tuttavia, gli indici hanno un sovraccarico e, come richiesto qui, potrebbe non essere sempre appropriato crearli per tutte le colonne FK. A tale scopo, è possibile rimuovere l'oggetto ForeignKeyIndexConvention durante la compilazione del modello:

protected override void ConfigureConventions(ModelConfigurationBuilder configurationBuilder)
{
    configurationBuilder.Conventions.Remove(typeof(ForeignKeyIndexConvention));
}

Esaminando la visualizzazione di debug del modello per Post il momento, si noterà che gli indici negli FK non sono stati creati:

  EntityType: Post
    Properties:
      Id (int) Required PK AfterSave:Throw ValueGenerated.OnAdd
      AuthorId (no field, int?) Shadow FK
      BlogId (no field, int) Shadow Required FK
    Navigations:
      Author (Author) ToPrincipal Author Inverse: Posts
      Blog (Blog) ToPrincipal Blog Inverse: Posts
    Keys:
      Id PK
    Foreign keys:
      Post {'AuthorId'} -> Author {'Id'} ToDependent: Posts ToPrincipal: Author ClientSetNull
      Post {'BlogId'} -> Blog {'Id'} ToDependent: Posts ToPrincipal: Blog Cascade

Quando si desidera, gli indici possono comunque essere creati in modo esplicito per le colonne chiave esterna, usando IndexAttribute:

[Index("BlogId")]
public class Post
{
    // ...
}

Oppure con la configurazione in OnModelCreating:

protected override void OnModelCreating(ModelBuilder modelBuilder)
{
    modelBuilder.Entity<Post>(entityTypeBuilder => entityTypeBuilder.HasIndex("BlogId"));
}

Esaminando di nuovo il Post tipo di entità, ora contiene l'indice BlogId , ma non l'indice AuthorId :

  EntityType: Post
    Properties:
      Id (int) Required PK AfterSave:Throw ValueGenerated.OnAdd
      AuthorId (no field, int?) Shadow FK
      BlogId (no field, int) Shadow Required FK Index
    Navigations:
      Author (Author) ToPrincipal Author Inverse: Posts
      Blog (Blog) ToPrincipal Blog Inverse: Posts
    Keys:
      Id PK
    Foreign keys:
      Post {'AuthorId'} -> Author {'Id'} ToDependent: Posts ToPrincipal: Author ClientSetNull
      Post {'BlogId'} -> Blog {'Id'} ToDependent: Posts ToPrincipal: Blog Cascade
    Indexes:
      BlogId

Suggerimento

Se il modello non usa attributi di mapping (ovvero annotazioni di dati) per la configurazione, tutte le convenzioni che terminano AttributeConvention possono essere rimosse in modo sicuro per velocizzare la compilazione del modello.

Aggiunta di una nuova convenzione

La rimozione delle convenzioni esistenti è un inizio, ma cosa accade per aggiungere convenzioni di compilazione di modelli completamente nuove? EF7 supporta anche questo.

Esempio: Vincolare la lunghezza delle proprietà discriminatorie

La strategia di mapping dell'ereditarietà per ogni gerarchia richiede una colonna discriminatoria per specificare il tipo rappresentato in una determinata riga. Per impostazione predefinita, EF usa una colonna stringa non associato per il discriminante, che garantisce che funzioni per qualsiasi lunghezza discriminatoria. Tuttavia, vincolare la lunghezza massima delle stringhe discriminatorie può rendere più efficiente l'archiviazione e le query. Si creerà una nuova convenzione che lo farà.

Le convenzioni di compilazione dei modelli di EF Core vengono attivate in base alle modifiche apportate al modello durante la compilazione. In questo modo il modello viene aggiornato man mano che viene eseguita la configurazione esplicita, vengono applicati gli attributi di mapping e vengono eseguite altre convenzioni. Per partecipare a questa operazione, ogni convenzione implementa una o più interfacce che determinano quando verrà attivata la convenzione. Ad esempio, una convenzione che implementa IEntityTypeAddedConvention verrà attivata ogni volta che viene aggiunto un nuovo tipo di entità al modello. Analogamente, una convenzione che implementa e IForeignKeyAddedConvention IKeyAddedConvention verrà attivata ogni volta che viene aggiunta una chiave o una chiave esterna al modello.

Conoscere le interfacce da implementare può essere difficile, poiché la configurazione eseguita al modello in un determinato momento può essere modificata o rimossa in un secondo momento. Ad esempio, una chiave può essere creata per convenzione, ma successivamente sostituita quando viene configurata in modo esplicito una chiave diversa.

Facciamo questo un po ' più concreto facendo un primo tentativo di implementazione della convenzione di lunghezza discriminatoria:

public class DiscriminatorLengthConvention1 : IEntityTypeBaseTypeChangedConvention
{
    public void ProcessEntityTypeBaseTypeChanged(
        IConventionEntityTypeBuilder entityTypeBuilder,
        IConventionEntityType? newBaseType,
        IConventionEntityType? oldBaseType,
        IConventionContext<IConventionEntityType> context)
    {
        var discriminatorProperty = entityTypeBuilder.Metadata.FindDiscriminatorProperty();
        if (discriminatorProperty != null
            && discriminatorProperty.ClrType == typeof(string))
        {
            discriminatorProperty.Builder.HasMaxLength(24);
        }
    }
}

Questa convenzione implementa IEntityTypeBaseTypeChangedConvention, il che significa che verrà attivato ogni volta che viene modificata la gerarchia di ereditarietà mappata per un tipo di entità. La convenzione trova e configura quindi la proprietà discriminatoria della stringa per la gerarchia.

Questa convenzione viene quindi usata chiamando Add in ConfigureConventions:

protected override void ConfigureConventions(ModelConfigurationBuilder configurationBuilder)
{
    configurationBuilder.Conventions.Add(_ =>  new DiscriminatorLengthConvention1());
}

Suggerimento

Anziché aggiungere direttamente un'istanza della convenzione, il Add metodo accetta una factory per la creazione di istanze della convenzione. In questo modo è possibile usare le dipendenze dal provider di servizi interni di EF Core. Poiché questa convenzione non ha dipendenze, il parametro del provider di servizi è denominato _, a indicare che non viene mai usato.

La compilazione del modello e l'analisi del Post tipo di entità mostra che questa proprietà ha funzionato: la proprietà discriminatoria è ora configurata in con una lunghezza massima di 24:

 Discriminator (no field, string) Shadow Required AfterSave:Throw MaxLength(24)

Ma cosa succede se ora configuriamo in modo esplicito una proprietà discriminatoria diversa? Ad esempio:

modelBuilder.Entity<Post>()
    .HasDiscriminator<string>("PostTypeDiscriminator")
    .HasValue<Post>("Post")
    .HasValue<FeaturedPost>("Featured");

Esaminando la visualizzazione di debug del modello, si scopre che la lunghezza del discriminatorio non è più configurata.

 PostTypeDiscriminator (no field, string) Shadow Required AfterSave:Throw

Ciò è dovuto al fatto che la proprietà discriminatoria configurata nella convenzione è stata successivamente rimossa quando è stato aggiunto il discriminatorio personalizzato. Potremmo tentare di risolvere questo problema implementando un'altra interfaccia sulla nostra convenzione per reagire alle modifiche discriminatorie, ma capire quale interfaccia implementare non è facile.

Fortunatamente, c'è un modo diverso per affrontare questo che rende le cose molto più facili. Molto tempo, non importa l'aspetto del modello mentre è in fase di compilazione, purché il modello finale sia corretto. Inoltre, la configurazione da applicare spesso non deve attivare altre convenzioni per reagire. Pertanto, la convenzione può implementare IModelFinalizingConvention. Le convenzioni di finalizzazione del modello vengono eseguite dopo il completamento di tutti gli altri edifici del modello e quindi hanno accesso allo stato finale del modello. Una convenzione di finalizzazione del modello in genere eseguirà l'iterazione dell'intero modello configurando gli elementi del modello man mano che passa. In questo caso, quindi, si troverà ogni discriminare nel modello e la si configurerà:

public class DiscriminatorLengthConvention2 : IModelFinalizingConvention
{
    public void ProcessModelFinalizing(IConventionModelBuilder modelBuilder, IConventionContext<IConventionModelBuilder> context)
    {
        foreach (var entityType in modelBuilder.Metadata.GetEntityTypes()
                     .Where(entityType => entityType.BaseType == null))
        {
            var discriminatorProperty = entityType.FindDiscriminatorProperty();
            if (discriminatorProperty != null
                && discriminatorProperty.ClrType == typeof(string))
            {
                discriminatorProperty.Builder.HasMaxLength(24);
            }
        }
    }
}

Dopo aver compilato il modello con questa nuova convenzione, si scopre che la lunghezza del discriminatorio è ora configurata correttamente anche se è stata personalizzata:

PostTypeDiscriminator (no field, string) Shadow Required AfterSave:Throw MaxLength(24)

Solo per divertimento, andiamo un ulteriore passo avanti e configuriamo la lunghezza massima in modo che sia la lunghezza del valore discriminatorio più lungo.

public class DiscriminatorLengthConvention3 : IModelFinalizingConvention
{
    public void ProcessModelFinalizing(IConventionModelBuilder modelBuilder, IConventionContext<IConventionModelBuilder> context)
    {
        foreach (var entityType in modelBuilder.Metadata.GetEntityTypes()
                     .Where(entityType => entityType.BaseType == null))
        {
            var discriminatorProperty = entityType.FindDiscriminatorProperty();
            if (discriminatorProperty != null
                && discriminatorProperty.ClrType == typeof(string))
            {
                var maxDiscriminatorValueLength =
                    entityType.GetDerivedTypesInclusive().Select(e => ((string)e.GetDiscriminatorValue()!).Length).Max();

                discriminatorProperty.Builder.HasMaxLength(maxDiscriminatorValueLength);
            }
        }
    }
}

Ora la lunghezza massima della colonna discriminatoria è 8, ovvero la lunghezza di "In primo piano", il valore discriminatorio più lungo in uso.

PostTypeDiscriminator (no field, string) Shadow Required AfterSave:Throw MaxLength(8)

Suggerimento

Ci si potrebbe chiedere se la convenzione deve anche creare un indice per la colonna discriminatoria. In GitHub è disponibile una discussione su questo argomento. La risposta breve è che a volte un indice può essere utile, ma la maggior parte del tempo probabilmente non sarà. Pertanto, è consigliabile creare indici appropriati in base alle esigenze, anziché avere una convenzione per farlo sempre. Ma se non si è d'accordo con questo, la convenzione precedente può essere facilmente modificata per creare un indice.

Esempio: lunghezza predefinita per tutte le proprietà stringa

Verrà ora esaminato un altro esempio in cui è possibile usare una convenzione di finalizzazione, impostando una lunghezza massima predefinita per qualsiasi proprietà stringa, come richiesto in GitHub. La convenzione è simile all'esempio precedente:

public class MaxStringLengthConvention : IModelFinalizingConvention
{
    public void ProcessModelFinalizing(IConventionModelBuilder modelBuilder, IConventionContext<IConventionModelBuilder> context)
    {
        foreach (var property in modelBuilder.Metadata.GetEntityTypes()
                     .SelectMany(
                         entityType => entityType.GetDeclaredProperties()
                             .Where(
                                 property => property.ClrType == typeof(string))))
        {
            property.Builder.HasMaxLength(512);
        }
    }
}

Questa convenzione è piuttosto semplice. Trova ogni proprietà stringa nel modello e ne imposta la lunghezza massima su 512. Esaminando la visualizzazione di debug nelle proprietà per Post, si noterà che tutte le proprietà stringa hanno ora una lunghezza massima di 512.

EntityType: Post
  Properties:
    Id (int) Required PK AfterSave:Throw ValueGenerated.OnAdd
    AuthorId (no field, int?) Shadow FK Index
    BlogId (no field, int) Shadow Required FK Index
    Content (string) Required MaxLength(512)
    Discriminator (no field, string) Shadow Required AfterSave:Throw MaxLength(512)
    PublishedOn (DateTime) Required
    Title (string) Required MaxLength(512)

Ma la Content proprietà dovrebbe probabilmente consentire più di 512 caratteri, o tutti i nostri post saranno abbastanza brevi! Questa operazione può essere eseguita senza modificare la convenzione configurando in modo esplicito la lunghezza massima per questa proprietà, usando un attributo di mapping:

[MaxLength(4000)]
public string Content { get; set; }

Oppure con il codice in OnModelCreating:

modelBuilder.Entity<Post>()
    .Property(post => post.Content)
    .HasMaxLength(4000);

Ora tutte le proprietà hanno una lunghezza massima di 512, ad eccezione Content del fatto che è stata configurata in modo esplicito con 4000:

EntityType: Post
  Properties:
    Id (int) Required PK AfterSave:Throw ValueGenerated.OnAdd
    AuthorId (no field, int?) Shadow FK Index
    BlogId (no field, int) Shadow Required FK Index
    Content (string) Required MaxLength(4000)
    Discriminator (no field, string) Shadow Required AfterSave:Throw MaxLength(512)
    PublishedOn (DateTime) Required
    Title (string) Required MaxLength(512)

Perché quindi la convenzione non ha sostituito la lunghezza massima configurata in modo esplicito? La risposta è che EF Core tiene traccia del modo in cui è stata eseguita ogni parte della configurazione. Questa proprietà è rappresentata dall'enumerazione ConfigurationSource . I diversi tipi di configurazione sono:

  • Explicit: l'elemento del modello è stato configurato in modo esplicito in OnModelCreating
  • DataAnnotation: l'elemento del modello è stato configurato usando un attributo di mapping (noto anche come annotazione dei dati) nel tipo CLR
  • Convention: l'elemento del modello è stato configurato da una convenzione di compilazione del modello

Le convenzioni non sostituiscono mai la configurazione contrassegnata come DataAnnotation o Explicit. Questo risultato viene ottenuto usando un "generatore di convenzioni", ad esempio , IConventionPropertyBuilderottenuto dalla Builder proprietà . Ad esempio:

property.Builder.HasMaxLength(512);

La chiamata HasMaxLength al generatore di convenzioni imposta la lunghezza massima solo se non è già stata configurata da un attributo di mapping o in OnModelCreating.

I metodi del generatore come questo hanno anche un secondo parametro: fromDataAnnotation. Impostare questa proprietà su true se la convenzione esegue la configurazione per conto di un attributo di mapping. Ad esempio:

property.Builder.HasMaxLength(512, fromDataAnnotation: true);

In questo modo l'oggetto ConfigurationSource viene DataAnnotationimpostato su , il che significa che il valore può ora essere sottoposto a override tramite mapping esplicito in OnModelCreating, ma non da convenzioni di attributi non di mapping.

Infine, prima di lasciare questo esempio, cosa accade se si usano entrambi MaxStringLengthConvention e DiscriminatorLengthConvention3 contemporaneamente? La risposta è che dipende dall'ordine in cui vengono aggiunte, poiché le convenzioni di finalizzazione del modello vengono eseguite nell'ordine in cui vengono aggiunte. Quindi, se MaxStringLengthConvention viene aggiunto per ultimo, verrà eseguito per ultimo e la lunghezza massima della proprietà discriminatoria verrà impostata su 512. Pertanto, in questo caso, è preferibile aggiungere DiscriminatorLengthConvention3 per ultimo in modo che possa sostituire la lunghezza massima predefinita solo per le proprietà discriminatorie, lasciando tutte le altre proprietà stringa come 512.

Sostituzione di una convenzione esistente

In alcuni casi, anziché rimuovere completamente una convenzione esistente, si vuole sostituirla con una convenzione che esegue fondamentalmente la stessa operazione, ma con il comportamento modificato. Ciò è utile perché la convenzione esistente implementerà già le interfacce necessarie in modo da essere attivata in modo appropriato.

Esempio: Mapping delle proprietà di consenso esplicito

EF Core esegue il mapping di tutte le proprietà pubbliche di lettura/scrittura per convenzione. Questo potrebbe non essere appropriato per il modo in cui vengono definiti i tipi di entità. Per modificare questo problema, è possibile sostituire con la propria implementazione che non esegue il PropertyDiscoveryConvention mapping di alcuna proprietà, a meno che non sia mappata in modo esplicito in OnModelCreating o contrassegnata con un nuovo attributo denominato Persist:

[AttributeUsage(AttributeTargets.Property | AttributeTargets.Field)]
public sealed class PersistAttribute : Attribute
{
}

Ecco la nuova convenzione:

public class AttributeBasedPropertyDiscoveryConvention : PropertyDiscoveryConvention
{
    public AttributeBasedPropertyDiscoveryConvention(ProviderConventionSetBuilderDependencies dependencies)
        : base(dependencies)
    {
    }

    public override void ProcessEntityTypeAdded(
        IConventionEntityTypeBuilder entityTypeBuilder,
        IConventionContext<IConventionEntityTypeBuilder> context)
        => Process(entityTypeBuilder);

    public override void ProcessEntityTypeBaseTypeChanged(
        IConventionEntityTypeBuilder entityTypeBuilder,
        IConventionEntityType? newBaseType,
        IConventionEntityType? oldBaseType,
        IConventionContext<IConventionEntityType> context)
    {
        if ((newBaseType == null
             || oldBaseType != null)
            && entityTypeBuilder.Metadata.BaseType == newBaseType)
        {
            Process(entityTypeBuilder);
        }
    }

    private void Process(IConventionEntityTypeBuilder entityTypeBuilder)
    {
        foreach (var memberInfo in GetRuntimeMembers())
        {
            if (Attribute.IsDefined(memberInfo, typeof(PersistAttribute), inherit: true))
            {
                entityTypeBuilder.Property(memberInfo);
            }
            else if (memberInfo is PropertyInfo propertyInfo
                     && Dependencies.TypeMappingSource.FindMapping(propertyInfo) != null)
            {
                entityTypeBuilder.Ignore(propertyInfo.Name);
            }
        }

        IEnumerable<MemberInfo> GetRuntimeMembers()
        {
            var clrType = entityTypeBuilder.Metadata.ClrType;

            foreach (var property in clrType.GetRuntimeProperties()
                         .Where(p => p.GetMethod != null && !p.GetMethod.IsStatic))
            {
                yield return property;
            }

            foreach (var property in clrType.GetRuntimeFields())
            {
                yield return property;
            }
        }
    }
}

Suggerimento

Quando si sostituisce una convenzione predefinita, la nuova implementazione della convenzione deve ereditare dalla classe convenzione esistente. Si noti che alcune convenzioni hanno implementazioni relazionali o specifiche del provider, nel qual caso la nuova implementazione della convenzione deve ereditare dalla classe di convenzioni esistente più specifica per il provider di database in uso.

La convenzione viene quindi registrata usando il Replace metodo in ConfigureConventions:

protected override void ConfigureConventions(ModelConfigurationBuilder configurationBuilder)
{
    configurationBuilder.Conventions.Replace<PropertyDiscoveryConvention>(
        serviceProvider => new AttributeBasedPropertyDiscoveryConvention(
            serviceProvider.GetRequiredService<ProviderConventionSetBuilderDependencies>()));
}

Suggerimento

Si tratta di un caso in cui la convenzione esistente presenta dipendenze, rappresentate dall'oggetto ProviderConventionSetBuilderDependencies dipendenza. Questi vengono ottenuti dal provider di servizi interno usando GetRequiredService e passati al costruttore della convenzione.

Questa convenzione funziona ottenendo tutte le proprietà e i campi leggibili dal tipo di entità specificato. Se il membro è attribuito con [Persist], viene eseguito il mapping chiamando:

entityTypeBuilder.Property(memberInfo);

D'altra parte, se il membro è una proprietà che altrimenti sarebbe stata mappata, viene esclusa dal modello utilizzando:

entityTypeBuilder.Ignore(propertyInfo.Name);

Si noti che questa convenzione consente di eseguire il mapping dei campi (oltre alle proprietà) purché siano contrassegnati con [Persist]. Ciò significa che è possibile usare campi privati come chiavi nascoste nel modello.

Si considerino ad esempio i tipi di entità seguenti:

public class LaundryBasket
{
    [Persist]
    [Key]
    private readonly int _id;

    [Persist]
    public int TenantId { get; init; }

    public bool IsClean { get; set; }

    public List<Garment> Garments { get; } = new();
}

public class Garment
{
    public Garment(string name, string color)
    {
        Name = name;
        Color = color;
    }

    [Persist]
    [Key]
    private readonly int _id;

    [Persist]
    public int TenantId { get; init; }

    [Persist]
    public string Name { get; }

    [Persist]
    public string Color { get; }

    public bool IsClean { get; set; }

    public LaundryBasket? Basket { get; set; }
}

Il modello compilato da questi tipi di entità è:

Model:
  EntityType: Garment
    Properties:
      _id (_id, int) Required PK AfterSave:Throw ValueGenerated.OnAdd
      Basket_id (no field, int?) Shadow FK Index
      Color (string) Required
      Name (string) Required
      TenantId (int) Required
    Navigations:
      Basket (LaundryBasket) ToPrincipal LaundryBasket Inverse: Garments
    Keys:
      _id PK
    Foreign keys:
      Garment {'Basket_id'} -> LaundryBasket {'_id'} ToDependent: Garments ToPrincipal: Basket ClientSetNull
    Indexes:
      Basket_id
  EntityType: LaundryBasket
    Properties:
      _id (_id, int) Required PK AfterSave:Throw ValueGenerated.OnAdd
      TenantId (int) Required
    Navigations:
      Garments (List<Garment>) Collection ToDependent Garment Inverse: Basket
    Keys:
      _id PK

Si noti che normalmente, IsClean sarebbe stato mappato, ma poiché non è contrassegnato con [Persist] (presumibilmente perché la pulizia non è una proprietà persistente del bucato), viene ora considerato come una proprietà non mappata.

Suggerimento

Non è stato possibile implementare questa convenzione come convenzione di finalizzazione del modello perché il mapping di una proprietà attiva molte altre convenzioni per l'esecuzione per configurare ulteriormente la proprietà mappata.

Mapping di stored procedure

Per impostazione predefinita, EF Core genera comandi di inserimento, aggiornamento ed eliminazione che funzionano direttamente con tabelle o viste aggiornabili. EF7 introduce il supporto per il mapping di questi comandi alle stored procedure.

Suggerimento

EF Core ha sempre supportato l'esecuzione di query tramite stored procedure. Il nuovo supporto in EF7 riguarda in modo esplicito l'uso di stored procedure per inserimenti, aggiornamenti ed eliminazioni.

Importante

Il supporto per il mapping di stored procedure non implica che le stored procedure siano consigliate.

Le stored procedure vengono mappate in OnModelCreating tramite InsertUsingStoredProcedure, UpdateUsingStoredProceduree DeleteUsingStoredProcedure. Ad esempio, per eseguire il mapping di stored procedure per un Person tipo di entità:

modelBuilder.Entity<Person>()
    .InsertUsingStoredProcedure(
        "People_Insert",
        storedProcedureBuilder =>
        {
            storedProcedureBuilder.HasParameter(a => a.Name);
            storedProcedureBuilder.HasResultColumn(a => a.Id);
        })
    .UpdateUsingStoredProcedure(
        "People_Update",
        storedProcedureBuilder =>
        {
            storedProcedureBuilder.HasOriginalValueParameter(person => person.Id);
            storedProcedureBuilder.HasOriginalValueParameter(person => person.Name);
            storedProcedureBuilder.HasParameter(person => person.Name);
            storedProcedureBuilder.HasRowsAffectedResultColumn();
        })
    .DeleteUsingStoredProcedure(
        "People_Delete",
        storedProcedureBuilder =>
        {
            storedProcedureBuilder.HasOriginalValueParameter(person => person.Id);
            storedProcedureBuilder.HasOriginalValueParameter(person => person.Name);
            storedProcedureBuilder.HasRowsAffectedResultColumn();
        });

Questa configurazione esegue il mapping alle stored procedure seguenti quando si usa SQL Server:

Per gli inserimenti

CREATE PROCEDURE [dbo].[People_Insert]
    @Name [nvarchar](max)
AS
BEGIN
      INSERT INTO [People] ([Name])
      OUTPUT INSERTED.[Id]
      VALUES (@Name);
END

Per gli aggiornamenti

CREATE PROCEDURE [dbo].[People_Update]
    @Id [int],
    @Name_Original [nvarchar](max),
    @Name [nvarchar](max)
AS
BEGIN
    UPDATE [People] SET [Name] = @Name
    WHERE [Id] = @Id AND [Name] = @Name_Original
    SELECT @@ROWCOUNT
END

Per le eliminazioni

CREATE PROCEDURE [dbo].[People_Delete]
    @Id [int],
    @Name_Original [nvarchar](max)
AS
BEGIN
    DELETE FROM [People]
    OUTPUT 1
    WHERE [Id] = @Id AND [Name] = @Name_Original;
END

Suggerimento

Non è necessario utilizzare stored procedure per ogni tipo nel modello o per tutte le operazioni su un determinato tipo. Ad esempio, se viene specificato solo DeleteUsingStoredProcedure per un determinato tipo, EF Core genererà SQL come di consueto per le operazioni di inserimento e aggiornamento e userà solo la stored procedure per le eliminazioni.

Il primo argomento passato a ogni metodo è il nome della stored procedure. Può essere omesso, nel qual caso EF Core userà il nome della tabella aggiunto con "_Insert", "_Update" o "_Delete". Pertanto, nell'esempio precedente, poiché la tabella è denominata "People", i nomi delle stored procedure possono essere rimossi senza alcuna modifica delle funzionalità.

Il secondo argomento è un generatore usato per configurare l'input e l'output della stored procedure, inclusi parametri, valori restituiti e colonne dei risultati.

Parametri

I parametri devono essere aggiunti al generatore nello stesso ordine in cui vengono visualizzati nella definizione della stored procedure.

Nota

I parametri possono essere denominati, ma EF Core chiama sempre stored procedure usando argomenti posizionali anziché argomenti denominati. Votare per Consenti la configurazione del mapping sproc per usare i nomi dei parametri per la chiamata se la chiamata in base al nome è un elemento a cui si è interessati.

Il primo argomento di ogni metodo del generatore di parametri specifica la proprietà nel modello a cui è associato il parametro. Può trattarsi di un'espressione lambda:

storedProcedureBuilder.HasParameter(a => a.Name);

Oppure una stringa, particolarmente utile quando si esegue il mapping delle proprietà shadow:

storedProcedureBuilder.HasParameter("Name");

I parametri sono, per impostazione predefinita, configurati per "input". I parametri "Output" o "input/output" possono essere configurati usando un generatore annidato. Ad esempio:

storedProcedureBuilder.HasParameter(
    document => document.RetrievedOn, 
    parameterBuilder => parameterBuilder.IsOutput());

Esistono tre diversi metodi di generatore per diversi tipi di parametri:

  • HasParameter specifica un parametro normale associato al valore corrente della proprietà specificata.
  • HasOriginalValueParameter specifica un parametro associato al valore originale della proprietà specificata. Il valore originale è il valore della proprietà quando è stata eseguita una query dal database, se noto. Se questo valore non è noto, viene invece usato il valore corrente. I parametri dei valori originali sono utili per i token di concorrenza.
  • HasRowsAffectedParameter specifica un parametro utilizzato per restituire il numero di righe interessate dalla stored procedure.

Suggerimento

I parametri dei valori originali devono essere usati per i valori chiave nelle stored procedure "update" e "delete". In questo modo, la riga corretta verrà aggiornata nelle versioni future di EF Core che supportano valori di chiave modificabili.

Restituzione di valori

EF Core supporta tre meccanismi per la restituzione di valori dalle stored procedure:

  • Parametri di output, come illustrato in precedenza.
  • Colonne risultato, specificate utilizzando il HasResultColumn metodo builder.
  • Il valore restituito, limitato alla restituzione del numero di righe interessate e viene specificato utilizzando il HasRowsAffectedReturnValue metodo builder.

I valori restituiti dalle stored procedure vengono spesso usati per i valori generati, predefiniti o calcolati, ad esempio da una Identity chiave o da una colonna calcolata. Ad esempio, la configurazione seguente specifica quattro colonne dei risultati:

entityTypeBuilder.InsertUsingStoredProcedure(
        storedProcedureBuilder =>
        {
            storedProcedureBuilder.HasParameter(document => document.Title);
            storedProcedureBuilder.HasResultColumn(document => document.Id);
            storedProcedureBuilder.HasResultColumn(document => document.FirstRecordedOn);
            storedProcedureBuilder.HasResultColumn(document => document.RetrievedOn);
            storedProcedureBuilder.HasResultColumn(document => document.RowVersion);
        });

Questi vengono usati per restituire:

  • Valore della chiave generato per la Id proprietà .
  • Valore predefinito generato dal database per la FirstRecordedOn proprietà .
  • Valore calcolato generato dal database per la RetrievedOn proprietà .
  • Token di concorrenza generato rowversion automaticamente per la RowVersion proprietà .

Questa configurazione esegue il mapping alla stored procedure seguente quando si usa SQL Server:

CREATE PROCEDURE [dbo].[Documents_Insert]
    @Title [nvarchar](max)
AS
BEGIN
    INSERT INTO [Documents] ([Title])
    OUTPUT INSERTED.[Id], INSERTED.[FirstRecordedOn], INSERTED.[RetrievedOn], INSERTED.[RowVersion]
    VALUES (@Title);
END

Concorrenza ottimistica

La concorrenza ottimistica funziona allo stesso modo con le stored procedure senza. La stored procedure deve:

  • Usare un token di concorrenza in una WHERE clausola per assicurarsi che la riga venga aggiornata solo se ha un token valido. Il valore usato per il token di concorrenza è in genere, ma non deve essere, il valore originale della proprietà del token di concorrenza.
  • Restituisce il numero di righe interessate in modo che EF Core possa confrontarlo con il numero previsto di righe interessate e generare un'eccezione DbUpdateConcurrencyException se i valori non corrispondono.

Ad esempio, la stored procedure di SQL Server seguente usa un rowversion token di concorrenza automatica:

CREATE PROCEDURE [dbo].[Documents_Update]
    @Id [int],
    @RowVersion_Original [rowversion],
    @Title [nvarchar](max),
    @RowVersion [rowversion] OUT
AS
BEGIN
    DECLARE @TempTable table ([RowVersion] varbinary(8));
    UPDATE [Documents] SET
        [Title] = @Title
    OUTPUT INSERTED.[RowVersion] INTO @TempTable
    WHERE [Id] = @Id AND [RowVersion] = @RowVersion_Original
    SELECT @@ROWCOUNT;
    SELECT @RowVersion = [RowVersion] FROM @TempTable;
END

Questa operazione è configurata in EF Core usando:

.UpdateUsingStoredProcedure(
    storedProcedureBuilder =>
    {
        storedProcedureBuilder.HasOriginalValueParameter(document => document.Id);
        storedProcedureBuilder.HasOriginalValueParameter(document => document.RowVersion);
        storedProcedureBuilder.HasParameter(document => document.Title);
        storedProcedureBuilder.HasParameter(document => document.RowVersion, parameterBuilder => parameterBuilder.IsOutput());
        storedProcedureBuilder.HasRowsAffectedResultColumn();
    });

Si noti che:

  • Viene usato il valore originale del RowVersion token di concorrenza.
  • La stored procedure usa una WHERE clausola per assicurarsi che la riga venga aggiornata solo se il RowVersion valore originale corrisponde.
  • Il nuovo valore generato per viene RowVersion inserito in una tabella temporanea.
  • Vengono restituiti il numero di righe interessate (@@ROWCOUNT) e il valore generato RowVersion .

Mapping delle gerarchie di ereditarietà alle stored procedure

EF Core richiede che le stored procedure seguano il layout di tabella per i tipi in una gerarchia. Ciò significa che:

  • Una gerarchia mappata tramite TPH deve avere una singola stored procedure di inserimento, aggiornamento e/o eliminazione destinata alla singola tabella mappata. Le stored procedure di inserimento e aggiornamento devono avere un parametro per il valore discriminatorio.
  • Una gerarchia mappata tramite TPT deve avere una stored procedure di inserimento, aggiornamento e/o eliminazione per ogni tipo, inclusi i tipi astratti. EF Core effettuerà più chiamate in base alle esigenze per aggiornare, inserire ed eliminare righe in tutte le tabelle.
  • Una gerarchia mappata tramite TPC deve disporre di una stored procedure di inserimento, aggiornamento e/o eliminazione per ogni tipo concreto, ma non di tipi astratti.

Nota

Se si usa una singola stored procedure per tipo concreto indipendentemente dalla strategia di mapping è un elemento a cui si è interessati, votare il supporto usando un singolo sproc per tipo concreto indipendentemente dalla strategia di mapping dell'ereditarietà.

Mapping dei tipi di proprietà alle stored procedure

La configurazione delle stored procedure per i tipi di proprietà viene eseguita nel generatore di tipi di proprietà annidati. Ad esempio:

modelBuilder.Entity<Person>(
    entityTypeBuilder =>
    {
        entityTypeBuilder.OwnsOne(
            author => author.Contact,
            ownedNavigationBuilder =>
            {
                ownedNavigationBuilder.ToTable("Contacts");
                ownedNavigationBuilder
                    .InsertUsingStoredProcedure(
                        storedProcedureBuilder =>
                        {
                            storedProcedureBuilder.HasParameter("PersonId");
                            storedProcedureBuilder.HasParameter(contactDetails => contactDetails.Phone);
                        })
                    .UpdateUsingStoredProcedure(
                        storedProcedureBuilder =>
                        {
                            storedProcedureBuilder.HasOriginalValueParameter("PersonId");
                            storedProcedureBuilder.HasParameter(contactDetails => contactDetails.Phone);
                            storedProcedureBuilder.HasRowsAffectedResultColumn();
                        })
                    .DeleteUsingStoredProcedure(
                        storedProcedureBuilder =>
                        {
                            storedProcedureBuilder.HasOriginalValueParameter("PersonId");
                            storedProcedureBuilder.HasRowsAffectedResultColumn();
            });
    });

Nota

Le stored procedure per inserimento, aggiornamento ed eliminazione supportano solo i tipi di proprietà di proprietà devono essere mappate a tabelle separate. Ovvero, il tipo di proprietà non può essere rappresentato dalle colonne nella tabella proprietaria. Votare per Aggiungere il supporto per la suddivisione "tabella" al mapping sproc CUD se si tratta di una limitazione che si desidera visualizzare rimossa.

Mapping di entità di join molti-a-molti alle stored procedure

La configurazione delle entità join molti-a-molti delle stored procedure può essere eseguita come parte della configurazione molti-a-molti. Ad esempio:

modelBuilder.Entity<Book>(
    entityTypeBuilder =>
    {
        entityTypeBuilder
            .HasMany(document => document.Authors)
            .WithMany(author => author.PublishedWorks)
            .UsingEntity<Dictionary<string, object>>(
                "BookPerson",
                builder => builder.HasOne<Person>().WithMany().OnDelete(DeleteBehavior.Cascade),
                builder => builder.HasOne<Book>().WithMany().OnDelete(DeleteBehavior.ClientCascade),
                joinTypeBuilder =>
                {
                    joinTypeBuilder
                        .InsertUsingStoredProcedure(
                            storedProcedureBuilder =>
                            {
                                storedProcedureBuilder.HasParameter("AuthorsId");
                                storedProcedureBuilder.HasParameter("PublishedWorksId");
                            })
                        .DeleteUsingStoredProcedure(
                            storedProcedureBuilder =>
                            {
                                storedProcedureBuilder.HasOriginalValueParameter("AuthorsId");
                                storedProcedureBuilder.HasOriginalValueParameter("PublishedWorksId");
                                storedProcedureBuilder.HasRowsAffectedResultColumn();
                            });
                });
    });

Intercettori ed eventi nuovi e migliorati

Gli intercettori di EF Core consentono l'intercettazione, la modifica e/o l'eliminazione delle operazioni di EF Core. EF Core include anche gli eventi e la registrazione .NET tradizionali.

EF7 include i miglioramenti seguenti per gli intercettori:

EF7 include anche nuovi eventi .NET tradizionali per:

Le sezioni seguenti illustrano alcuni esempi dell'uso di queste nuove funzionalità di intercettazione.

Azioni semplici per la creazione di entità

Suggerimento

Il codice illustrato di seguito proviene da SimpleMaterializationSample.cs.

Il nuovo IMaterializationInterceptor supporta l'intercettazione prima e dopo la creazione di un'istanza di entità e prima e dopo le proprietà di tale istanza. L'intercettore può modificare o sostituire l'istanza dell'entità in ogni punto. Questo consente:

  • Impostazione di proprietà non mappate o metodi di chiamata necessari per la convalida, i valori calcolati o i flag.
  • Uso di una factory per creare istanze.
  • La creazione di un'istanza di entità diversa da quella di Entity Framework viene in genere creata, ad esempio un'istanza da una cache o di un tipo proxy.
  • Inserimento di servizi in un'istanza di entità.

Si supponga, ad esempio, di voler tenere traccia del tempo in cui un'entità è stata recuperata dal database, in modo che possa essere visualizzata a un utente che modifica i dati. A tale scopo, definiamo prima un'interfaccia:

public interface IHasRetrieved
{
    DateTime Retrieved { get; set; }
}

L'uso di un'interfaccia è comune con gli intercettori perché consente allo stesso intercettore di lavorare con molti tipi di entità diversi. Ad esempio:

public class Customer : IHasRetrieved
{
    public int Id { get; set; }
    public string Name { get; set; } = null!;
    public string? PhoneNumber { get; set; }

    [NotMapped]
    public DateTime Retrieved { get; set; }
}

Si noti che l'attributo [NotMapped] viene usato per indicare che questa proprietà viene usata solo durante l'utilizzo dell'entità e non deve essere mantenuta nel database.

L'intercettore deve quindi implementare il metodo appropriato da IMaterializationInterceptor e impostare l'ora recuperata:

public class SetRetrievedInterceptor : IMaterializationInterceptor
{
    public object InitializedInstance(MaterializationInterceptionData materializationData, object instance)
    {
        if (instance is IHasRetrieved hasRetrieved)
        {
            hasRetrieved.Retrieved = DateTime.UtcNow;
        }

        return instance;
    }
}

Un'istanza di questo intercettore viene registrata durante la configurazione di DbContext:

public class CustomerContext : DbContext
{
    private static readonly SetRetrievedInterceptor _setRetrievedInterceptor = new();

    public DbSet<Customer> Customers
        => Set<Customer>();

    protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
        => optionsBuilder
            .AddInterceptors(_setRetrievedInterceptor)
            .UseSqlite("Data Source = customers.db");
}

Suggerimento

Questo intercettore è senza stato, che è comune, quindi viene creata e condivisa una singola istanza tra tutte le DbContext istanze.

A questo punto, ogni volta che viene eseguita una Customer query dal database, la Retrieved proprietà verrà impostata automaticamente. Ad esempio:

await using (var context = new CustomerContext())
{
    var customer = await context.Customers.SingleAsync(e => e.Name == "Alice");
    Console.WriteLine($"Customer '{customer.Name}' was retrieved at '{customer.Retrieved.ToLocalTime()}'");
}

Produce output:

Customer 'Alice' was retrieved at '9/22/2022 5:25:54 PM'

Inserimento di servizi in entità

Suggerimento

Il codice illustrato di seguito proviene da InjectLoggerSample.cs.

EF Core dispone già del supporto predefinito per l'inserimento di alcuni servizi speciali nelle istanze di contesto; ad esempio, vedere Caricamento differita senza proxy, che funziona inserendo il ILazyLoader servizio.

Un IMaterializationInterceptor oggetto può essere utilizzato per generalizzare questo oggetto in qualsiasi servizio. Nell'esempio seguente viene illustrato come inserire un oggetto ILogger in entità in modo che possano eseguire la propria registrazione.

Nota

L'inserimento di servizi in entità associa tali tipi di entità ai servizi inseriti, che alcune persone considerano un anti-modello.

Come in precedenza, viene usata un'interfaccia per definire le operazioni che è possibile eseguire.

public interface IHasLogger
{
    ILogger? Logger { get; set; }
}

E i tipi di entità che registrano devono implementare questa interfaccia. Ad esempio:

public class Customer : IHasLogger
{
    private string? _phoneNumber;

    public int Id { get; set; }
    public string Name { get; set; } = null!;

    public string? PhoneNumber
    {
        get => _phoneNumber;
        set
        {
            Logger?.LogInformation(1, $"Updating phone number for '{Name}' from '{_phoneNumber}' to '{value}'.");

            _phoneNumber = value;
        }
    }

    [NotMapped]
    public ILogger? Logger { get; set; }
}

Questa volta, l'intercettore deve implementare IMaterializationInterceptor.InitializedInstance, che viene chiamato dopo la creazione di ogni istanza di entità e i relativi valori delle proprietà sono stati inizializzati. L'intercettore ottiene un oggetto ILogger dal contesto e lo inizializza IHasLogger.Logger con esso:

public class LoggerInjectionInterceptor : IMaterializationInterceptor
{
    private ILogger? _logger;

    public object InitializedInstance(MaterializationInterceptionData materializationData, object instance)
    {
        if (instance is IHasLogger hasLogger)
        {
            _logger ??= materializationData.Context.GetService<ILoggerFactory>().CreateLogger("CustomersLogger");
            hasLogger.Logger = _logger;
        }

        return instance;
    }
}

Questa volta viene usata una nuova istanza dell'intercettore per ogni DbContext istanza, poiché l'oggetto ILogger ottenuto può cambiare per ogni DbContext istanza e viene ILogger memorizzato nella cache nell'intercettore:

protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
    => optionsBuilder.AddInterceptors(new LoggerInjectionInterceptor());

A questo punto, ogni volta che Customer.PhoneNumber viene modificato, questa modifica verrà registrata nel log dell'applicazione. Ad esempio:

info: CustomersLogger[1]
      Updating phone number for 'Alice' from '+1 515 555 0123' to '+1 515 555 0125'.

Intercettazione dell'albero delle espressioni LINQ

Suggerimento

Il codice illustrato di seguito proviene da QueryInterceptionSample.cs.

EF Core usa query LINQ .NET. Questo implica in genere l'uso del compilatore C#, VB o F# per compilare un albero delle espressioni che viene quindi convertito da EF Core nel linguaggio SQL appropriato. Si consideri, ad esempio, un metodo che restituisce una pagina di clienti:

Task<List<Customer>> GetPageOfCustomers(string sortProperty, int page)
{
    using var context = new CustomerContext();

    return context.Customers
        .OrderBy(e => EF.Property<object>(e, sortProperty))
        .Skip(page * 20).Take(20).ToListAsync();
}

Suggerimento

Questa query usa il EF.Property metodo per specificare la proprietà in base alla quale eseguire l'ordinamento. Ciò consente all'applicazione di passare dinamicamente il nome della proprietà, consentendo l'ordinamento in base a qualsiasi proprietà del tipo di entità. Tenere presente che l'ordinamento in base a colonne non indicizzate può essere lento.

Questa operazione funzionerà correttamente, purché la proprietà utilizzata per l'ordinamento restituisca sempre un ordinamento stabile. Ma questo potrebbe non essere sempre il caso. Ad esempio, la query LINQ precedente genera quanto segue in SQLite durante l'ordinamento in Customer.Citybase a :

SELECT "c"."Id", "c"."City", "c"."Name", "c"."PhoneNumber"
FROM "Customers" AS "c"
ORDER BY "c"."City"
LIMIT @__p_1 OFFSET @__p_0

Se sono presenti più clienti con lo stesso City, l'ordinamento di questa query non è stabile. Ciò potrebbe causare risultati mancanti o duplicati come pagine utente attraverso i dati.

Un modo comune per risolvere questo problema consiste nell'eseguire un ordinamento secondario in base alla chiave primaria. Tuttavia, anziché aggiungerlo manualmente a ogni query, EF7 consente l'intercettazione dell'albero delle espressioni di query in cui è possibile aggiungere dinamicamente l'ordinamento secondario. Per semplificare questa operazione, si userà di nuovo un'interfaccia, questa volta per qualsiasi entità con una chiave primaria integer:

public interface IHasIntKey
{
    int Id { get; }
}

Questa interfaccia viene implementata dai tipi di entità di interesse:

public class Customer : IHasIntKey
{
    public int Id { get; set; }
    public string Name { get; set; } = null!;
    public string? City { get; set; }
    public string? PhoneNumber { get; set; }
}

È quindi necessario un intercettore che implementa IQueryExpressionInterceptor

public class KeyOrderingExpressionInterceptor : IQueryExpressionInterceptor
{
    public Expression QueryCompilationStarting(Expression queryExpression, QueryExpressionEventData eventData)
        => new KeyOrderingExpressionVisitor().Visit(queryExpression);

    private class KeyOrderingExpressionVisitor : ExpressionVisitor
    {
        private static readonly MethodInfo ThenByMethod
            = typeof(Queryable).GetMethods()
                .Single(m => m.Name == nameof(Queryable.ThenBy) && m.GetParameters().Length == 2);

        protected override Expression VisitMethodCall(MethodCallExpression? methodCallExpression)
        {
            var methodInfo = methodCallExpression!.Method;
            if (methodInfo.DeclaringType == typeof(Queryable)
                && methodInfo.Name == nameof(Queryable.OrderBy)
                && methodInfo.GetParameters().Length == 2)
            {
                var sourceType = methodCallExpression.Type.GetGenericArguments()[0];
                if (typeof(IHasIntKey).IsAssignableFrom(sourceType))
                {
                    var lambdaExpression = (LambdaExpression)((UnaryExpression)methodCallExpression.Arguments[1]).Operand;
                    var entityParameterExpression = lambdaExpression.Parameters[0];

                    return Expression.Call(
                        ThenByMethod.MakeGenericMethod(
                            sourceType,
                            typeof(int)),
                        base.VisitMethodCall(methodCallExpression),
                        Expression.Lambda(
                            typeof(Func<,>).MakeGenericType(entityParameterExpression.Type, typeof(int)),
                            Expression.Property(entityParameterExpression, nameof(IHasIntKey.Id)),
                            entityParameterExpression));
                }
            }

            return base.VisitMethodCall(methodCallExpression);
        }
    }
}

Questo probabilmente sembra piuttosto complicato- ed è! L'uso degli alberi delle espressioni in genere non è facile. Vediamo cosa sta accadendo:

  • Fondamentalmente, l'intercettore incapsula un oggetto ExpressionVisitor. Il visitatore esegue l'override VisitMethodCalldi , che verrà chiamato ogni volta che viene eseguita una chiamata a un metodo nell'albero delle espressioni di query.

  • Il visitatore controlla se si tratta o meno di una chiamata al OrderBy metodo a cui si è interessati.

  • In caso affermativo, il visitatore verifica ulteriormente se la chiamata al metodo generico è per un tipo che implementa IHasIntKey l'interfaccia.

  • A questo punto si sa che la chiamata al metodo è nel formato OrderBy(e => ...). L'espressione lambda viene estratta da questa chiamata e viene ottenuto il parametro usato in tale espressione, ovvero .e

  • A questo momento si compila un nuovo MethodCallExpression usando il Expression.Call metodo builder. In questo caso, il metodo chiamato è ThenBy(e => e.Id). Questa operazione viene compilata usando il parametro estratto in precedenza e un accesso alle Id proprietà dell'interfaccia IHasIntKey .

  • L'input in questa chiamata è l'originale OrderBy(e => ...)e quindi il risultato finale è un'espressione per OrderBy(e => ...).ThenBy(e => e.Id).

  • Questa espressione modificata viene restituita dal visitatore, il che significa che la query LINQ è stata modificata in modo appropriato per includere una ThenBy chiamata.

  • EF Core continua e compila questa espressione di query nel codice SQL appropriato per il database in uso.

Questo intercettore viene registrato nello stesso modo in cui è stato fatto per il primo esempio. L'esecuzione GetPageOfCustomers genera ora il codice SQL seguente:

SELECT "c"."Id", "c"."City", "c"."Name", "c"."PhoneNumber"
FROM "Customers" AS "c"
ORDER BY "c"."City", "c"."Id"
LIMIT @__p_1 OFFSET @__p_0

Questo ora produrrà sempre un ordinamento stabile, anche se sono presenti più clienti con lo stesso City.

Uff! Si tratta di un sacco di codice per apportare una semplice modifica a una query. E peggio ancora, potrebbe anche non funzionare per tutte le query. È notoriamente difficile scrivere un visitatore di espressioni che riconosce tutte le forme di query che dovrebbe, e nessuno di quelli che non dovrebbe. Ad esempio, ciò probabilmente non funzionerà se l'ordinamento viene eseguito in una sottoquery.

Questo ci porta a un punto critico sugli intercettori- sempre chiedersi se c'è un modo più semplice di fare quello che vuoi. Gli intercettori sono potenti, ma è facile sbagliare le cose. Sono, come dice, un modo semplice per spararti in piedi.

Si supponga, ad esempio, di modificare il GetPageOfCustomers metodo come segue:

Task<List<Customer>> GetPageOfCustomers2(string sortProperty, int page)
{
    using var context = new CustomerContext();

    return context.Customers
        .OrderBy(e => EF.Property<object>(e, sortProperty))
        .ThenBy(e => e.Id)
        .Skip(page * 20).Take(20).ToListAsync();
}

In questo caso l'oggetto ThenBy viene semplicemente aggiunto alla query. Sì, potrebbe essere necessario eseguire separatamente a ogni query, ma è semplice, facile da comprendere e funzionerà sempre.

Intercettazione della concorrenza ottimistica

Suggerimento

Il codice illustrato di seguito proviene da OptimisticConcurrencyInterceptionSample.cs.

EF Core supporta il modello di concorrenza ottimistica verificando che il numero di righe effettivamente interessate da un aggiornamento o un'eliminazione corrisponda al numero di righe previste. Questo è spesso associato a un token di concorrenza; ovvero, un valore di colonna che corrisponderà solo al valore previsto se la riga non è stata aggiornata dopo la lettura del valore previsto.

Ef segnala una violazione della concorrenza ottimistica generando un'eccezione DbUpdateConcurrencyException. In EF7 sono ISaveChangesInterceptor disponibili nuovi metodi ThrowingConcurrencyException e ThrowingConcurrencyExceptionAsync che vengono chiamati prima che venga generata l'eccezione DbUpdateConcurrencyException . Questi punti di intercettazione consentono di eliminare l'eccezione, possibilmente abbinata a modifiche asincrone del database per risolvere la violazione.

Ad esempio, se due richieste tentano di eliminare la stessa entità contemporaneamente, la seconda eliminazione potrebbe non riuscire perché la riga nel database non esiste più. Il risultato finale potrebbe essere corretto. L'entità è stata comunque eliminata. L'intercettore seguente illustra come eseguire questa operazione:

public class SuppressDeleteConcurrencyInterceptor : ISaveChangesInterceptor
{
    public InterceptionResult ThrowingConcurrencyException(
        ConcurrencyExceptionEventData eventData,
        InterceptionResult result)
    {
        if (eventData.Entries.All(e => e.State == EntityState.Deleted))
        {
            Console.WriteLine("Suppressing Concurrency violation for command:");
            Console.WriteLine(((RelationalConcurrencyExceptionEventData)eventData).Command.CommandText);

            return InterceptionResult.Suppress();
        }

        return result;
    }

    public ValueTask<InterceptionResult> ThrowingConcurrencyExceptionAsync(
        ConcurrencyExceptionEventData eventData,
        InterceptionResult result,
        CancellationToken cancellationToken = default)
        => new(ThrowingConcurrencyException(eventData, result));
}

Ci sono diverse cose che vale la pena notare su questo intercettore:

  • Vengono implementati sia i metodi di intercettazione sincroni che asincroni. Questo è importante se l'applicazione può chiamare SaveChanges o SaveChangesAsync. Tuttavia, se tutto il codice dell'applicazione è asincrono, è necessario implementare solo ThrowingConcurrencyExceptionAsync . Analogamente, se l'applicazione non usa mai metodi di database asincroni, è necessario implementare solo ThrowingConcurrencyException . Questo vale in genere per tutti gli intercettori con metodi sincroni e asincroni. Potrebbe essere utile implementare il metodo in cui l'applicazione non viene usata per generare, solo nel caso in cui si verifichi un errore di sincronizzazione/codice asincrono.
  • L'intercettore ha accesso agli EntityEntry oggetti per le entità salvate. In questo caso, viene usato per verificare se si verifica o meno la violazione della concorrenza per un'operazione di eliminazione.
  • Se l'applicazione usa un provider di database relazionale, è possibile eseguire il cast dell'oggetto ConcurrencyExceptionEventData a un RelationalConcurrencyExceptionEventData oggetto . In questo modo vengono fornite informazioni aggiuntive specifiche relazionali sull'operazione di database eseguita. In questo caso, il testo del comando relazionale viene stampato nella console.
  • La restituzione InterceptionResult.Suppress() indica a EF Core di eliminare l'azione che sta per eseguire, in questo caso, generando .DbUpdateConcurrencyException Questa possibilità di modificare il comportamento di EF Core, anziché semplicemente osservare le operazioni di EF Core, è una delle funzionalità più potenti degli intercettori.

Inizializzazione differita di un stringa di connessione

Suggerimento

Il codice illustrato di seguito proviene da LazyConnectionStringSample.cs.

Le stringhe di connessione sono spesso asset statici letti da un file di configurazione. Questi possono essere passati facilmente a UseSqlServer o simili durante la configurazione di un oggetto DbContext. Tuttavia, a volte il stringa di connessione può cambiare per ogni istanza del contesto. Ad esempio, ogni tenant in un sistema multi-tenant può avere un stringa di connessione diverso.

EF7 semplifica la gestione delle connessioni dinamiche e delle stringa di connessione tramite miglioramenti a IDbConnectionInterceptor. Questo inizia con la possibilità di configurare senza DbContext alcuna stringa di connessione. Ad esempio:

services.AddDbContext<CustomerContext>(
    b => b.UseSqlServer());

È quindi possibile implementare uno dei IDbConnectionInterceptor metodi per configurare la connessione prima di usarla. ConnectionOpeningAsyncè una buona scelta, poiché può eseguire un'operazione asincrona per ottenere il stringa di connessione, trovare un token di accesso e così via. Si supponga, ad esempio, che un servizio con ambito alla richiesta corrente comprenda il tenant corrente:

services.AddScoped<ITenantConnectionStringFactory, TestTenantConnectionStringFactory>();

Avviso

Eseguire una ricerca asincrona per un stringa di connessione, un token di accesso o un token di accesso simile ogni volta che è necessario può essere molto lento. Prendere in considerazione la memorizzazione nella cache di questi elementi e aggiornare periodicamente solo la stringa o il token memorizzato nella cache. Ad esempio, i token di accesso possono essere spesso usati per un periodo di tempo significativo prima di dover essere aggiornati.

Questa operazione può essere inserita in ogni DbContext istanza usando l'inserimento del costruttore:

public class CustomerContext : DbContext
{
    private readonly ITenantConnectionStringFactory _connectionStringFactory;

    public CustomerContext(
        DbContextOptions<CustomerContext> options,
        ITenantConnectionStringFactory connectionStringFactory)
        : base(options)
    {
        _connectionStringFactory = connectionStringFactory;
    }

    // ...
}

Questo servizio viene quindi usato quando si costruisce l'implementazione dell'intercettore per il contesto:

protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
    => optionsBuilder.AddInterceptors(
        new ConnectionStringInitializationInterceptor(_connectionStringFactory));

Infine, l'intercettore usa questo servizio per ottenere il stringa di connessione in modo asincrono e impostarlo la prima volta che viene usata la connessione:

public class ConnectionStringInitializationInterceptor : DbConnectionInterceptor
{
    private readonly IClientConnectionStringFactory _connectionStringFactory;

    public ConnectionStringInitializationInterceptor(IClientConnectionStringFactory connectionStringFactory)
    {
        _connectionStringFactory = connectionStringFactory;
    }

    public override InterceptionResult ConnectionOpening(
        DbConnection connection,
        ConnectionEventData eventData,
        InterceptionResult result)
        => throw new NotSupportedException("Synchronous connections not supported.");

    public override async ValueTask<InterceptionResult> ConnectionOpeningAsync(
        DbConnection connection, ConnectionEventData eventData, InterceptionResult result,
        CancellationToken cancellationToken = new())
    {
        if (string.IsNullOrEmpty(connection.ConnectionString))
        {
            connection.ConnectionString = (await _connectionStringFactory.GetConnectionStringAsync(cancellationToken));
        }

        return result;
    }
}

Nota

Il stringa di connessione viene ottenuto solo la prima volta che viene usata una connessione. Successivamente, il stringa di connessione archiviato in DbConnection verrà usato senza cercare un nuovo stringa di connessione.

Suggerimento

Questo intercettore esegue l'override del metodo non asincrono ConnectionOpening da generare perché il servizio per ottenere il stringa di connessione deve essere chiamato da un percorso di codice asincrono.

Registrazione delle statistiche delle query di SQL Server

Suggerimento

Il codice illustrato di seguito proviene da QueryStatisticsLoggerSample.cs.

Creare infine due intercettori che interagiscono per inviare statistiche di query di SQL Server al log applicazioni. Per generare le statistiche, è necessario IDbCommandInterceptor eseguire due operazioni.

In primo luogo, l'intercettore prefissi i comandi con SET STATISTICS IO ON, che indica a SQL Server di inviare statistiche al client dopo l'utilizzo di un set di risultati:

public override ValueTask<InterceptionResult<DbDataReader>> ReaderExecutingAsync(
    DbCommand command,
    CommandEventData eventData,
    InterceptionResult<DbDataReader> result,
    CancellationToken cancellationToken = default)
{
    command.CommandText = "SET STATISTICS IO ON;" + Environment.NewLine + command.CommandText;

    return new(result);
}

In secondo luogo, l'intercettore implementerà il nuovo DataReaderClosingAsync metodo, che viene chiamato dopo il completamento dell'utilizzo DbDataReader dei risultati, ma prima che sia stato chiuso. Quando SQL Server invia statistiche, li inserisce in un secondo risultato nel lettore, quindi a questo punto l'intercettore legge tale risultato chiamando NextResultAsync che popola le statistiche nella connessione.

public override async ValueTask<InterceptionResult> DataReaderClosingAsync(
    DbCommand command,
    DataReaderClosingEventData eventData,
    InterceptionResult result)
{
    await eventData.DataReader.NextResultAsync();

    return result;
}

Il secondo intercettore è necessario per ottenere le statistiche dalla connessione e scriverle nel logger dell'applicazione. Per questo motivo si userà un IDbConnectionInterceptoroggetto , implementando il nuovo ConnectionCreated metodo. ConnectionCreated viene chiamato immediatamente dopo che EF Core ha creato una connessione e quindi può essere usato per eseguire una configurazione aggiuntiva di tale connessione. In questo caso, l'intercettore ottiene un oggetto ILogger e quindi esegue l'hook nell'evento SqlConnection.InfoMessage per registrare i messaggi.

public override DbConnection ConnectionCreated(ConnectionCreatedEventData eventData, DbConnection result)
{
    var logger = eventData.Context!.GetService<ILoggerFactory>().CreateLogger("InfoMessageLogger");
    ((SqlConnection)eventData.Connection).InfoMessage += (_, args) =>
    {
        logger.LogInformation(1, args.Message);
    };
    return result;
}

Importante

I ConnectionCreating metodi e ConnectionCreated vengono chiamati solo quando EF Core crea un oggetto DbConnection. Non verranno chiamati se l'applicazione crea DbConnection e la passa a EF Core.

L'esecuzione di codice che usa questi intercettori mostra le statistiche di query di SQL Server nel log:

info: Microsoft.EntityFrameworkCore.Database.Command[20101]
      Executed DbCommand (4ms) [Parameters=[@p0='?' (Size = 4000), @p1='?' (Size = 4000), @p2='?' (Size = 4000), @p3='?' (Size = 4000)], CommandType='Text', CommandTimeout='30']
      SET STATISTICS IO ON;
      SET IMPLICIT_TRANSACTIONS OFF;
      SET NOCOUNT ON;
      MERGE [Customers] USING (
      VALUES (@p0, @p1, 0),
      (@p2, @p3, 1)) AS i ([Name], [PhoneNumber], _Position) ON 1=0
      WHEN NOT MATCHED THEN
      INSERT ([Name], [PhoneNumber])
      VALUES (i.[Name], i.[PhoneNumber])
      OUTPUT INSERTED.[Id], i._Position;
info: InfoMessageLogger[1]
      Table 'Customers'. Scan count 0, logical reads 5, physical reads 0, page server reads 0, read-ahead reads 0, page server read-ahead reads 0, lob logical reads 0, lob physical reads 0, lob page server reads 0, lob read-ahead reads 0, lob page server read-ahead reads 0.
info: Microsoft.EntityFrameworkCore.Database.Command[20101]
      Executed DbCommand (2ms) [Parameters=[], CommandType='Text', CommandTimeout='30']
      SET STATISTICS IO ON;
      SELECT TOP(2) [c].[Id], [c].[Name], [c].[PhoneNumber]
      FROM [Customers] AS [c]
      WHERE [c].[Name] = N'Alice'
info: InfoMessageLogger[1]
      Table 'Customers'. Scan count 1, logical reads 2, physical reads 0, page server reads 0, read-ahead reads 0, page server read-ahead reads 0, lob logical reads 0, lob physical reads 0, lob page server reads 0, lob read-ahead reads 0, lob page server read-ahead reads 0.

Miglioramenti delle query

EF7 contiene molti miglioramenti nella traduzione delle query LINQ.

GroupBy come operatore finale

Suggerimento

Il codice illustrato di seguito proviene da GroupByFinalOperatorSample.cs.

EF7 supporta l'uso GroupBy come operatore finale in una query. Ad esempio, la query LINQ seguente:

var query = context.Books.GroupBy(s => s.Price);

Viene convertito nel codice SQL seguente quando si usa SQL Server:

SELECT [b].[Price], [b].[Id], [b].[AuthorId]
FROM [Books] AS [b]
ORDER BY [b].[Price]

Nota

Questo tipo di GroupBy non si traduce direttamente in SQL, quindi EF Core esegue il raggruppamento sui risultati restituiti. Tuttavia, ciò non comporta il trasferimento di dati aggiuntivi dal server.

GroupJoin come operatore finale

Suggerimento

Il codice illustrato di seguito proviene da GroupJoinFinalOperatorSample.cs.

EF7 supporta l'uso GroupJoin come operatore finale in una query. Ad esempio, la query LINQ seguente:

var query = context.Customers.GroupJoin(
    context.Orders, c => c.Id, o => o.CustomerId, (c, os) => new { Customer = c, Orders = os });

Viene convertito nel codice SQL seguente quando si usa SQL Server:

SELECT [c].[Id], [c].[Name], [t].[Id], [t].[Amount], [t].[CustomerId]
FROM [Customers] AS [c]
OUTER APPLY (
    SELECT [o].[Id], [o].[Amount], [o].[CustomerId]
    FROM [Orders] AS [o]
    WHERE [c].[Id] = [o].[CustomerId]
) AS [t]
ORDER BY [c].[Id]

Tipo di entità GroupBy

Suggerimento

Il codice illustrato di seguito proviene da GroupByEntityTypeSample.cs.

EF7 supporta il raggruppamento in base a un tipo di entità. Ad esempio, la query LINQ seguente:

var query = context.Books
    .GroupBy(s => s.Author)
    .Select(s => new { Author = s.Key, MaxPrice = s.Max(p => p.Price) });

Viene convertito nel codice SQL seguente quando si usa SQLite:

SELECT [a].[Id], [a].[Name], MAX([b].[Price]) AS [MaxPrice]
FROM [Books] AS [b]
INNER JOIN [Author] AS [a] ON [b].[AuthorId] = [a].[Id]
GROUP BY [a].[Id], [a].[Name]

Tenere presente che il raggruppamento in base a una proprietà univoca, ad esempio la chiave primaria, sarà sempre più efficiente rispetto al raggruppamento in base a un tipo di entità. Tuttavia, il raggruppamento in base ai tipi di entità può essere usato sia per i tipi di entità con chiave che per i tipi di entità senza chiave.

Inoltre, il raggruppamento in base a un tipo di entità con una chiave primaria comporterà sempre un gruppo per ogni istanza di entità, poiché ogni entità deve avere un valore di chiave univoco. A volte vale la pena cambiare l'origine della query in modo che il raggruppamento in non sia necessario. Ad esempio, la query seguente restituisce gli stessi risultati della query precedente:

var query = context.Authors
    .Select(a => new { Author = a, MaxPrice = a.Books.Max(b => b.Price) });

Questa query viene convertita nel codice SQL seguente quando si usa SQLite:

SELECT [a].[Id], [a].[Name], (
    SELECT MAX([b].[Price])
    FROM [Books] AS [b]
    WHERE [a].[Id] = [b].[AuthorId]) AS [MaxPrice]
FROM [Authors] AS [a]

Le sottoquery non fanno riferimento a colonne non raggruppate dalla query esterna

Suggerimento

Il codice illustrato di seguito proviene da UngroupedColumnsQuerySample.cs.

In EF Core 6.0 una GROUP BY clausola fa riferimento a colonne nella query esterna, che ha esito negativo con alcuni database ed è inefficiente in altre. Ad esempio, si consideri la query seguente:

var query = from s in (from i in context.Invoices
                       group i by i.History.Month
                       into g
                       select new { Month = g.Key, Total = g.Sum(p => p.Amount), })
            select new
            {
                s.Month, s.Total, Payment = context.Payments.Where(p => p.History.Month == s.Month).Sum(p => p.Amount)
            };

In EF Core 6.0 in SQL Server questo è stato convertito in:

SELECT DATEPART(month, [i].[History]) AS [Month], COALESCE(SUM([i].[Amount]), 0.0) AS [Total], (
    SELECT COALESCE(SUM([p].[Amount]), 0.0)
    FROM [Payments] AS [p]
    WHERE DATEPART(month, [p].[History]) = DATEPART(month, [i].[History])) AS [Payment]
FROM [Invoices] AS [i]
GROUP BY DATEPART(month, [i].[History])

In EF7 la traduzione è:

SELECT [t].[Key] AS [Month], COALESCE(SUM([t].[Amount]), 0.0) AS [Total], (
    SELECT COALESCE(SUM([p].[Amount]), 0.0)
    FROM [Payments] AS [p]
    WHERE DATEPART(month, [p].[History]) = [t].[Key]) AS [Payment]
FROM (
    SELECT [i].[Amount], DATEPART(month, [i].[History]) AS [Key]
    FROM [Invoices] AS [i]
) AS [t]
GROUP BY [t].[Key]

Le raccolte di sola lettura possono essere usate per Contains

Suggerimento

Il codice illustrato di seguito proviene da ReadOnlySetQuerySample.cs.

EF7 supporta l'uso di Contains quando gli elementi da cercare sono contenuti in un IReadOnlySet oggetto o IReadOnlyCollectiono IReadOnlyList. Ad esempio, la query LINQ seguente:

IReadOnlySet<int> searchIds = new HashSet<int> { 1, 3, 5 };
var query = context.Customers.Where(p => p.Orders.Any(l => searchIds.Contains(l.Id)));

Viene convertito nel codice SQL seguente quando si usa SQL Server:

SELECT [c].[Id], [c].[Name]
FROM [Customers] AS [c]
WHERE EXISTS (
    SELECT 1
    FROM [Orders] AS [o]
    WHERE [c].[Id] = [o].[Customer1Id] AND [o].[Id] IN (1, 3, 5))

Traduzioni per le funzioni di aggregazione

EF7 introduce una migliore estendibilità per i provider per tradurre le funzioni di aggregazione. Questo e altri lavori in questo settore hanno portato a diverse nuove traduzioni tra provider, tra cui:

Nota

Le funzioni di aggregazione che agiscono sull'argomento IEnumerable vengono in genere convertite solo nelle GroupBy query. Votare per Supportare i tipi spaziali nelle colonne JSON se si è interessati a rimuovere questa limitazione.

Funzioni di aggregazione di stringhe

Suggerimento

Il codice illustrato di seguito proviene da StringAggregateFunctionsSample.cs.

Le query che usano Join e Concat vengono ora convertite quando appropriato. Ad esempio:

var query = context.Posts
    .GroupBy(post => post.Author)
    .Select(grouping => new { Author = grouping.Key, Books = string.Join("|", grouping.Select(post => post.Title)) });

Questa query viene convertita nell'esempio seguente quando si usa SQL Server:

SELECT [a].[Id], [a].[Name], COALESCE(STRING_AGG([p].[Title], N'|'), N'') AS [Books]
FROM [Posts] AS [p]
LEFT JOIN [Authors] AS [a] ON [p].[AuthorId] = [a].[Id]
GROUP BY [a].[Id], [a].[Name]

In combinazione con altre funzioni stringa, queste traduzioni consentono una manipolazione complessa delle stringhe nel server. Ad esempio:

var query = context.Posts
    .GroupBy(post => post.Author!.Name)
    .Select(
        grouping =>
            new
            {
                PostAuthor = grouping.Key,
                Blogs = string.Concat(
                    grouping
                        .Select(post => post.Blog.Name)
                        .Distinct()
                        .Select(postName => "'" + postName + "' ")),
                ContentSummaries = string.Join(
                    " | ",
                    grouping
                        .Where(post => post.Content.Length >= 10)
                        .Select(post => "'" + post.Content.Substring(0, 10) + "' "))
            });

Questa query viene convertita nell'esempio seguente quando si usa SQL Server:

SELECT [t].[Name], (N'''' + [t0].[Name]) + N''' ', [t0].[Name], [t].[c]
FROM (
    SELECT [a].[Name], COALESCE(STRING_AGG(CASE
        WHEN CAST(LEN([p].[Content]) AS int) >= 10 THEN COALESCE((N'''' + COALESCE(SUBSTRING([p].[Content], 0 + 1, 10), N'')) + N''' ', N'')
    END, N' | '), N'') AS [c]
    FROM [Posts] AS [p]
    LEFT JOIN [Authors] AS [a] ON [p].[AuthorId] = [a].[Id]
    GROUP BY [a].[Name]
) AS [t]
OUTER APPLY (
    SELECT DISTINCT [b].[Name]
    FROM [Posts] AS [p0]
    LEFT JOIN [Authors] AS [a0] ON [p0].[AuthorId] = [a0].[Id]
    INNER JOIN [Blogs] AS [b] ON [p0].[BlogId] = [b].[Id]
    WHERE [t].[Name] = [a0].[Name] OR ([t].[Name] IS NULL AND [a0].[Name] IS NULL)
) AS [t0]
ORDER BY [t].[Name]

Funzioni di aggregazione spaziale

Suggerimento

Il codice illustrato di seguito proviene da SpatialAggregateFunctionsSample.cs.

È ora possibile che i provider di database che supportano NetTopologySuite traslare le funzioni di aggregazione spaziali seguenti:

Suggerimento

Queste traduzioni sono state implementate dal team per SQL Server e SQLite. Per altri provider, contattare il gestore del provider per aggiungere il supporto se è stato implementato per tale provider.

Ad esempio:

var query = context.Caches
    .Where(cache => cache.Location.X < -90)
    .GroupBy(cache => cache.Owner)
    .Select(
        grouping => new { Id = grouping.Key, Combined = GeometryCombiner.Combine(grouping.Select(cache => cache.Location)) });

Questa query viene convertita nel codice SQL seguente quando si usa SQL Server:

SELECT [c].[Owner] AS [Id], geography::CollectionAggregate([c].[Location]) AS [Combined]
FROM [Caches] AS [c]
WHERE [c].[Location].Long < -90.0E0
GROUP BY [c].[Owner]

Funzioni di aggregazione statistiche

Suggerimento

Il codice illustrato di seguito proviene da StatisticalAggregateFunctionsSample.cs.

Le traduzioni di SQL Server sono state implementate per le funzioni statistiche seguenti:

Suggerimento

Queste traduzioni sono state implementate dal team per SQL Server. Per altri provider, contattare il gestore del provider per aggiungere il supporto se è stato implementato per tale provider.

Ad esempio:

var query = context.Downloads
    .GroupBy(download => download.Uploader.Id)
    .Select(
        grouping => new
        {
            Author = grouping.Key,
            TotalCost = grouping.Sum(d => d.DownloadCount),
            AverageViews = grouping.Average(d => d.DownloadCount),
            VariancePopulation = EF.Functions.VariancePopulation(grouping.Select(d => d.DownloadCount)),
            VarianceSample = EF.Functions.VarianceSample(grouping.Select(d => d.DownloadCount)),
            StandardDeviationPopulation = EF.Functions.StandardDeviationPopulation(grouping.Select(d => d.DownloadCount)),
            StandardDeviationSample = EF.Functions.StandardDeviationSample(grouping.Select(d => d.DownloadCount))
        });

Questa query viene convertita nel codice SQL seguente quando si usa SQL Server:

SELECT [u].[Id] AS [Author], COALESCE(SUM([d].[DownloadCount]), 0) AS [TotalCost], AVG(CAST([d].[DownloadCount] AS float)) AS [AverageViews], VARP([d].[DownloadCount]) AS [VariancePopulation], VAR([d].[DownloadCount]) AS [VarianceSample], STDEVP([d].[DownloadCount]) AS [StandardDeviationPopulation], STDEV([d].[DownloadCount]) AS [StandardDeviationSample]
FROM [Downloads] AS [d]
INNER JOIN [Uploader] AS [u] ON [d].[UploaderId] = [u].[Id]
GROUP BY [u].[Id]

Traduzione di string.IndexOf

Suggerimento

Il codice illustrato di seguito proviene da MiscellaneousTranslationsSample.cs.

EF7 ora viene convertito String.IndexOf nelle query LINQ. Ad esempio:

var query = context.Posts
    .Select(post => new { post.Title, IndexOfEntity = post.Content.IndexOf("Entity") })
    .Where(post => post.IndexOfEntity > 0);

Questa query viene convertita nel codice SQL seguente quando si usa SQL Server:

SELECT [p].[Title], CAST(CHARINDEX(N'Entity', [p].[Content]) AS int) - 1 AS [IndexOfEntity]
FROM [Posts] AS [p]
WHERE (CAST(CHARINDEX(N'Entity', [p].[Content]) AS int) - 1) > 0

Conversione di per i tipi di GetType entità

Suggerimento

Il codice illustrato di seguito proviene da MiscellaneousTranslationsSample.cs.

EF7 ora viene convertito Object.GetType() nelle query LINQ. Ad esempio:

var query = context.Posts.Where(post => post.GetType() == typeof(Post));

Questa query viene convertita nel codice SQL seguente quando si usa SQL Server con ereditarietà TPH:

SELECT [p].[Id], [p].[AuthorId], [p].[BlogId], [p].[Content], [p].[Discriminator], [p].[PublishedOn], [p].[Title], [p].[PromoText]
FROM [Posts] AS [p]
WHERE [p].[Discriminator] = N'Post'

Si noti che questa query restituisce solo Post le istanze di tipo Poste non quelle di qualsiasi tipo derivato. Questo comportamento è diverso da una query che usa is o OfType, che restituirà anche istanze di qualsiasi tipo derivato. Si consideri ad esempio la query:

var query = context.Posts.OfType<Post>();

Che si traduce in SQL diverso:

      SELECT [p].[Id], [p].[AuthorId], [p].[BlogId], [p].[Content], [p].[Discriminator], [p].[PublishedOn], [p].[Title], [p].[PromoText]
      FROM [Posts] AS [p]

E restituirà entrambe le Post entità e FeaturedPost .

Supporto per AT TIME ZONE

Suggerimento

Il codice illustrato di seguito proviene da MiscellaneousTranslationsSample.cs.

EF7 introduce nuove AtTimeZone funzioni per DateTime e DateTimeOffset. Queste funzioni si traducono in AT TIME ZONE clausole in SQL generato. Ad esempio:

var query = context.Posts
    .Select(
        post => new
        {
            post.Title,
            PacificTime = EF.Functions.AtTimeZone(post.PublishedOn, "Pacific Standard Time"),
            UkTime = EF.Functions.AtTimeZone(post.PublishedOn, "GMT Standard Time"),
        });

Questa query viene convertita nel codice SQL seguente quando si usa SQL Server:

SELECT [p].[Title], [p].[PublishedOn] AT TIME ZONE 'Pacific Standard Time' AS [PacificTime], [p].[PublishedOn] AT TIME ZONE 'GMT Standard Time' AS [UkTime]
FROM [Posts] AS [p]

Suggerimento

Queste traduzioni sono state implementate dal team per SQL Server. Per altri provider, contattare il gestore del provider per aggiungere il supporto se è stato implementato per tale provider.

Inclusione filtrata per gli spostamenti nascosti

Suggerimento

Il codice illustrato di seguito proviene da MiscellaneousTranslationsSample.cs.

I metodi Include possono ora essere usati con EF.Property. In questo modo è possibile filtrare e ordinare anche le proprietà di navigazione private o gli spostamenti privati rappresentati dai campi. Ad esempio:

var query = context.Blogs.Include(
    blog => EF.Property<ICollection<Post>>(blog, "Posts")
        .Where(post => post.Content.Contains(".NET"))
        .OrderBy(post => post.Title));

Equivale a:

var query = context.Blogs.Include(
    blog => Posts
        .Where(post => post.Content.Contains(".NET"))
        .OrderBy(post => post.Title));

Ma non deve Blog.Posts essere accessibile pubblicamente.

Quando si usa SQL Server, entrambe le query precedenti vengono convertite in:

SELECT [b].[Id], [b].[Name], [t].[Id], [t].[AuthorId], [t].[BlogId], [t].[Content], [t].[Discriminator], [t].[PublishedOn], [t].[Title], [t].[PromoText]
FROM [Blogs] AS [b]
LEFT JOIN (
    SELECT [p].[Id], [p].[AuthorId], [p].[BlogId], [p].[Content], [p].[Discriminator], [p].[PublishedOn], [p].[Title], [p].[PromoText]
    FROM [Posts] AS [p]
    WHERE [p].[Content] LIKE N'%.NET%'
) AS [t] ON [b].[Id] = [t].[BlogId]
ORDER BY [b].[Id], [t].[Title]

Traduzione di Cosmos per Regex.IsMatch

Suggerimento

Il codice illustrato di seguito proviene da CosmosQueriesSample.cs.

EF7 supporta l'uso Regex.IsMatch nelle query LINQ su Azure Cosmos DB. Ad esempio:

var containsInnerT = await context.Triangles
    .Where(o => Regex.IsMatch(o.Name, "[a-z]t[a-z]", RegexOptions.IgnoreCase))
    .ToListAsync();

Esegue la conversione nel codice SQL seguente:

SELECT c
FROM root c
WHERE ((c["Discriminator"] = "Triangle") AND RegexMatch(c["Name"], "[a-z]t[a-z]", "i"))

Miglioramenti apportati all'API DbContext e al comportamento

EF7 contiene un'ampia gamma di piccoli miglioramenti alle DbContext classi correlate e .

Suggerimento

Il codice per gli esempi in questa sezione proviene da DbContextApiSample.cs.

Suppressor per le proprietà DbSet non inizializzate

Le proprietà pubblica e impostabili DbSet in un DbContext oggetto vengono inizializzate automaticamente da EF Core quando viene costruito .DbContext Si consideri ad esempio la definizione seguente DbContext :

public class SomeDbContext : DbContext
{
    public DbSet<Blog> Blogs { get; set; }
}

La Blogs proprietà verrà impostata su un'istanza DbSet<Blog> come parte della costruzione dell'istanza DbContext . In questo modo è possibile usare il contesto per le query senza passaggi aggiuntivi.

Tuttavia, dopo l'introduzione dei tipi di riferimento nullable C#, il compilatore avvisa ora che la proprietà Blogs non nullable non è inizializzata:

[CS8618] Non-nullable property 'Blogs' must contain a non-null value when exiting constructor. Consider declaring the property as nullable.

Questo è un avvertimento fasullo; la proprietà è impostata su un valore non Null di EF Core. Inoltre, dichiarando la proprietà come nullable, l'avviso verrà eliminato, ma questa non è una buona idea perché, concettualmente, la proprietà non è nullable e non sarà mai null.

EF7 contiene un DiagnosticSuppressor per DbSet le proprietà di un oggetto DbContext che impedisce al compilatore di generare questo avviso.

Suggerimento

Questo modello ha avuto origine nei giorni in cui le proprietà automatiche C# erano molto limitate. Con C# moderno, è consigliabile impostare le proprietà automatiche di sola lettura e quindi inizializzarle in modo esplicito nel DbContext costruttore oppure ottenere l'istanza memorizzata DbSet nella cache dal contesto quando necessario. Ad esempio: public DbSet<Blog> Blogs => Set<Blog>().

Distinguere l'annullamento dagli errori nei log

In alcuni casi un'applicazione annulla in modo esplicito una query o un'altra operazione di database. Questa operazione viene in genere eseguita usando un CancellationToken oggetto passato al metodo che esegue l'operazione.

In EF Core 6, gli eventi registrati quando un'operazione viene annullata sono uguali a quelli registrati quando l'operazione non riesce per altri motivi. EF7 introduce nuovi eventi di log specifici per le operazioni di database annullate. Questi nuovi eventi sono, per impostazione predefinita, registrati a Debug livello. La tabella seguente illustra gli eventi pertinenti e i relativi livelli di log predefiniti:

Evento Descrizione Livello di log predefinito
CoreEventId.QueryIterationFailed Errore durante l'elaborazione dei risultati di una query. LogLevel.Error
CoreEventId.SaveChangesFailed Si è verificato un errore durante il tentativo di salvare le modifiche nel database. LogLevel.Error
RelationalEventId.CommandError Si è verificato un errore durante l'esecuzione di un comando di database. LogLevel.Error
CoreEventId.QueryCanceled Una query è stata annullata. LogLevel.Debug
CoreEventId.SaveChangesCanceled Il comando del database è stato annullato durante il tentativo di salvare le modifiche. LogLevel.Debug
RelationalEventId.CommandCanceled L'esecuzione di un oggetto DbCommand è stata annullata. LogLevel.Debug

Nota

L'annullamento viene rilevato esaminando l'eccezione anziché controllare il token di annullamento. Ciò significa che gli annullamenti non attivati tramite il token di annullamento verranno comunque rilevati e registrati in questo modo.

Nuovi IProperty overload e INavigation per EntityEntry i metodi

Il codice che usa il modello di Entity Framework include spesso un oggetto IProperty o INavigation che rappresenta i metadati della proprietà o dello spostamento. Un EntityEntry viene quindi usato per ottenere il valore di proprietà/navigazione o eseguire una query sul relativo stato. Tuttavia, prima di EF7, questo richiedeva il passaggio del nome della proprietà o dello spostamento ai metodi di EntityEntry, che quindi eseguivano di nuovo la ricerca di IProperty o INavigation. In EF7 l'oggetto IProperty o INavigation può invece essere passato direttamente, evitando la ricerca aggiuntiva.

Si consideri ad esempio un metodo per trovare tutti gli elementi di pari livello di una determinata entità:

public static IEnumerable<TEntity> FindSiblings<TEntity>(
    this DbContext context, TEntity entity, string navigationToParent)
    where TEntity : class
{
    var parentEntry = context.Entry(entity).Reference(navigationToParent);

    return context.Entry(parentEntry.CurrentValue!)
        .Collection(parentEntry.Metadata.Inverse!)
        .CurrentValue!
        .OfType<TEntity>()
        .Where(e => !ReferenceEquals(e, entity));
}

Questo metodo trova l'elemento padre di una determinata entità e quindi passa l'inverso INavigation al Collection metodo della voce padre. Questi metadati vengono quindi usati per restituire tutti gli elementi di pari livello dell'elemento padre specificato. Di seguito è riportato un esempio dell'uso:


Console.WriteLine($"Siblings to {post.Id}: '{post.Title}' are...");
foreach (var sibling in context.FindSiblings(post, nameof(post.Blog)))
{
    Console.WriteLine($"    {sibling.Id}: '{sibling.Title}'");
}

E l'output:

Siblings to 1: 'Announcing Entity Framework 7 Preview 7: Interceptors!' are...
    5: 'Productivity comes to .NET MAUI in Visual Studio 2022'
    6: 'Announcing .NET 7 Preview 7'
    7: 'ASP.NET Core updates in .NET 7 Preview 7'

EntityEntry per i tipi di entità di tipo condiviso

EF Core può usare lo stesso tipo CLR per più tipi di entità diversi. Questi tipi sono noti come "tipi di entità di tipo condiviso" e vengono spesso usati per eseguire il mapping di un tipo di dizionario con coppie chiave/valore usate per le proprietà del tipo di entità. Ad esempio, è possibile definire un BuildMetadata tipo di entità senza definire un tipo CLR dedicato:

modelBuilder.SharedTypeEntity<Dictionary<string, object>>(
    "BuildMetadata", b =>
    {
        b.IndexerProperty<int>("Id");
        b.IndexerProperty<string>("Tag");
        b.IndexerProperty<Version>("Version");
        b.IndexerProperty<string>("Hash");
        b.IndexerProperty<bool>("Prerelease");
    });

Si noti che il tipo di entità di tipo condiviso deve essere denominato. In questo caso, il nome è BuildMetadata. Questi tipi di entità vengono quindi accessibili usando un DbSet oggetto per il tipo di entità ottenuto usando il nome. Ad esempio:

public DbSet<Dictionary<string, object>> BuildMetadata
    => Set<Dictionary<string, object>>("BuildMetadata");

Può DbSet essere usato per tenere traccia delle istanze di entità:

await context.BuildMetadata.AddAsync(
    new Dictionary<string, object>
    {
        { "Tag", "v7.0.0-rc.1.22426.7" },
        { "Version", new Version(7, 0, 0) },
        { "Prerelease", true },
        { "Hash", "dc0f3e8ef10eb1464b27f0fd4704f53c01226036" }
    });

Ed eseguire query:

var builds = await context.BuildMetadata
    .Where(metadata => !EF.Property<bool>(metadata, "Prerelease"))
    .OrderBy(metadata => EF.Property<string>(metadata, "Tag"))
    .ToListAsync();

In EF7 è ora disponibile anche un Entry metodo su DbSet cui è possibile usare per ottenere lo stato di un'istanza, anche se non è ancora stato rilevato. Ad esempio:

var state = context.BuildMetadata.Entry(build).State;

ContextInitialized viene ora registrato come Debug

In EF7 l'evento ContextInitialized viene registrato a Debug livello. Ad esempio:

dbug: 10/7/2022 12:27:52.379 CoreEventId.ContextInitialized[10403] (Microsoft.EntityFrameworkCore.Infrastructure)
      Entity Framework Core 7.0.0 initialized 'BlogsContext' using provider 'Microsoft.EntityFrameworkCore.SqlServer:7.0.0' with options: SensitiveDataLoggingEnabled using NetTopologySuite

Nelle versioni precedenti è stato registrato a Information livello di . Ad esempio:

info: 10/7/2022 12:30:34.757 CoreEventId.ContextInitialized[10403] (Microsoft.EntityFrameworkCore.Infrastructure)
      Entity Framework Core 7.0.0 initialized 'BlogsContext' using provider 'Microsoft.EntityFrameworkCore.SqlServer:7.0.0' with options: SensitiveDataLoggingEnabled using NetTopologySuite

Se lo si desidera, il livello di log può essere nuovamente modificato in Information:

optionsBuilder.ConfigureWarnings(
    builder =>
    {
        builder.Log((CoreEventId.ContextInitialized, LogLevel.Information));
    });

IEntityEntryGraphIterator è utilizzabile pubblicamente

In EF7 il IEntityEntryGraphIterator servizio può essere usato dalle applicazioni. Si tratta del servizio usato internamente quando si individua un grafico di entità da tenere traccia e anche da TrackGraph. Ecco un esempio che scorre tutte le entità raggiungibili da un'entità iniziale:

var blogEntry = context.ChangeTracker.Entries<Blog>().First();
var found = new HashSet<object>();
var iterator = context.GetService<IEntityEntryGraphIterator>();
iterator.TraverseGraph(new EntityEntryGraphNode<HashSet<object>>(blogEntry, found, null, null), node =>
{
    if (node.NodeState.Contains(node.Entry.Entity))
    {
        return false;
    }

    Console.Write($"Found with '{node.Entry.Entity.GetType().Name}'");

    if (node.InboundNavigation != null)
    {
        Console.Write($" by traversing '{node.InboundNavigation.Name}' from '{node.SourceEntry!.Entity.GetType().Name}'");
    }

    Console.WriteLine();

    node.NodeState.Add(node.Entry.Entity);

    return true;
});

Console.WriteLine();
Console.WriteLine($"Finished iterating. Found {found.Count} entities.");
Console.WriteLine();

Avviso:

  • L'iteratore interrompe l'attraversamento da un determinato nodo quando il delegato di callback restituisce false. Questo esempio tiene traccia delle entità visitate e restituisce false quando l'entità è già stata visitata. Ciò impedisce cicli infiniti risultanti da cicli nel grafico.
  • L'oggetto EntityEntryGraphNode<TState> consente di passare lo stato all'interno senza acquisiscerlo nel delegato.
  • Per ogni nodo visitato diverso dal primo, il nodo da cui è stato individuato e lo spostamento individuato tramite vengono passati al callback.

Miglioramenti alla creazione di modelli

EF7 contiene un'ampia gamma di piccoli miglioramenti nella creazione di modelli.

Suggerimento

Il codice per gli esempi in questa sezione proviene da ModelBuildingSample.cs.

Gli indici possono essere crescente o decrescente

Per impostazione predefinita, EF Core crea indici ascendenti. EF7 supporta anche la creazione di indici decrescente. Ad esempio:

modelBuilder
    .Entity<Post>()
    .HasIndex(post => post.Title)
    .IsDescending();

In alternativa, usando l'attributo Index di mapping:

[Index(nameof(Title), AllDescending = true)]
public class Post
{
    public int Id { get; set; }

    [MaxLength(64)]
    public string? Title { get; set; }
}

Questa operazione è raramente utile per gli indici su una singola colonna, poiché il database può usare lo stesso indice per l'ordinamento in entrambe le direzioni. Tuttavia, questo non è il caso per gli indici compositi su più colonne in cui l'ordine in ogni colonna può essere importante. EF Core supporta questa funzionalità consentendo a più colonne di avere un ordinamento diverso definito per ogni colonna. Ad esempio:

modelBuilder
    .Entity<Blog>()
    .HasIndex(blog => new { blog.Name, blog.Owner })
    .IsDescending(false, true);

In alternativa, usando un attributo di mapping:

[Index(nameof(Name), nameof(Owner), IsDescending = new[] { false, true })]
public class Blog
{
    public int Id { get; set; }

    [MaxLength(64)]
    public string? Name { get; set; }

    [MaxLength(64)]
    public string? Owner { get; set; }

    public List<Post> Posts { get; } = new();
}

Questo risultato è il codice SQL seguente quando si usa SQL Server:

CREATE INDEX [IX_Blogs_Name_Owner] ON [Blogs] ([Name], [Owner] DESC);

Infine, è possibile creare più indici nello stesso set ordinato di colonne assegnando i nomi degli indici. Ad esempio:

modelBuilder
    .Entity<Blog>()
    .HasIndex(blog => new { blog.Name, blog.Owner }, "IX_Blogs_Name_Owner_1")
    .IsDescending(false, true);

modelBuilder
    .Entity<Blog>()
    .HasIndex(blog => new { blog.Name, blog.Owner }, "IX_Blogs_Name_Owner_2")
    .IsDescending(true, true);

In alternativa, usando gli attributi di mapping:

[Index(nameof(Name), nameof(Owner), IsDescending = new[] { false, true }, Name = "IX_Blogs_Name_Owner_1")]
[Index(nameof(Name), nameof(Owner), IsDescending = new[] { true, true }, Name = "IX_Blogs_Name_Owner_2")]
public class Blog
{
    public int Id { get; set; }

    [MaxLength(64)]
    public string? Name { get; set; }

    [MaxLength(64)]
    public string? Owner { get; set; }

    public List<Post> Posts { get; } = new();
}

In questo modo viene generato il codice SQL seguente in SQL Server:

CREATE INDEX [IX_Blogs_Name_Owner_1] ON [Blogs] ([Name], [Owner] DESC);
CREATE INDEX [IX_Blogs_Name_Owner_2] ON [Blogs] ([Name] DESC, [Owner] DESC);

Attributo di mapping per chiavi composite

EF7 introduce un nuovo attributo di mapping (noto anche come "annotazione dati") per specificare la proprietà o le proprietà della chiave primaria di qualsiasi tipo di entità. A differenza di System.ComponentModel.DataAnnotations.KeyAttribute, PrimaryKeyAttribute viene inserito nella classe del tipo di entità anziché nella proprietà della chiave. Ad esempio:

[PrimaryKey(nameof(PostKey))]
public class Post
{
    public int PostKey { get; set; }
}

In questo modo è possibile definire chiavi composite in modo naturale:

[PrimaryKey(nameof(PostId), nameof(CommentId))]
public class Comment
{
    public int PostId { get; set; }
    public int CommentId { get; set; }
    public string CommentText { get; set; } = null!;
}

La definizione dell'indice nella classe significa anche che può essere usata per specificare proprietà o campi privati come chiavi, anche se in genere vengono ignorati durante la compilazione del modello di Entity Framework. Ad esempio:

[PrimaryKey(nameof(_id))]
public class Tag
{
    private readonly int _id;
}

DeleteBehavior attributo di mapping

EF7 introduce un attributo di mapping (noto anche come "annotazione dati") per specificare per DeleteBehavior una relazione. Ad esempio, le relazioni obbligatorie vengono create con per DeleteBehavior.Cascade impostazione predefinita. Questa opzione può essere modificata DeleteBehavior.NoAction in per impostazione predefinita usando DeleteBehaviorAttribute:

public class Post
{
    public int Id { get; set; }
    public string? Title { get; set; }

    [DeleteBehavior(DeleteBehavior.NoAction)]
    public Blog Blog { get; set; } = null!;
}

In questo modo verranno disabilitate le eliminazioni a catena per la relazione Blog-Post.

Proprietà mappate a nomi di colonna diversi

Alcuni modelli di mapping comportano il mapping della stessa proprietà CLR a una colonna in ognuna di più tabelle diverse. EF7 consente a queste colonne di avere nomi diversi. Si consideri, ad esempio, una semplice gerarchia di ereditarietà:

public abstract class Animal
{
    public int Id { get; set; }
    public string Breed { get; set; } = null!;
}

public class Cat : Animal
{
    public string? EducationalLevel { get; set; }
}

public class Dog : Animal
{
    public string? FavoriteToy { get; set; }
}

Con la strategia di mapping dell'ereditarietà TPT, questi tipi verranno mappati a tre tabelle. Tuttavia, la colonna chiave primaria in ogni tabella può avere un nome diverso. Ad esempio:

CREATE TABLE [Animals] (
    [Id] int NOT NULL IDENTITY,
    [Breed] nvarchar(max) NOT NULL,
    CONSTRAINT [PK_Animals] PRIMARY KEY ([Id])
);

CREATE TABLE [Cats] (
    [CatId] int NOT NULL,
    [EducationalLevel] nvarchar(max) NULL,
    CONSTRAINT [PK_Cats] PRIMARY KEY ([CatId]),
    CONSTRAINT [FK_Cats_Animals_CatId] FOREIGN KEY ([CatId]) REFERENCES [Animals] ([Id]) ON DELETE CASCADE
);

CREATE TABLE [Dogs] (
    [DogId] int NOT NULL,
    [FavoriteToy] nvarchar(max) NULL,
    CONSTRAINT [PK_Dogs] PRIMARY KEY ([DogId]),
    CONSTRAINT [FK_Dogs_Animals_DogId] FOREIGN KEY ([DogId]) REFERENCES [Animals] ([Id]) ON DELETE CASCADE
);

EF7 consente di configurare questo mapping usando un generatore di tabelle annidato:

modelBuilder.Entity<Animal>().ToTable("Animals");

modelBuilder.Entity<Cat>()
    .ToTable(
        "Cats",
        tableBuilder => tableBuilder.Property(cat => cat.Id).HasColumnName("CatId"));

modelBuilder.Entity<Dog>()
    .ToTable(
        "Dogs",
        tableBuilder => tableBuilder.Property(dog => dog.Id).HasColumnName("DogId"));

Con il mapping di ereditarietà TPC, la Breed proprietà può anche essere mappata a nomi di colonna diversi in tabelle diverse. Si considerino ad esempio le tabelle TPC seguenti:

CREATE TABLE [Cats] (
    [CatId] int NOT NULL DEFAULT (NEXT VALUE FOR [AnimalSequence]),
    [CatBreed] nvarchar(max) NOT NULL,
    [EducationalLevel] nvarchar(max) NULL,
    CONSTRAINT [PK_Cats] PRIMARY KEY ([CatId])
);

CREATE TABLE [Dogs] (
    [DogId] int NOT NULL DEFAULT (NEXT VALUE FOR [AnimalSequence]),
    [DogBreed] nvarchar(max) NOT NULL,
    [FavoriteToy] nvarchar(max) NULL,
    CONSTRAINT [PK_Dogs] PRIMARY KEY ([DogId])
);

EF7 supporta questo mapping di tabelle:

modelBuilder.Entity<Animal>().UseTpcMappingStrategy();

modelBuilder.Entity<Cat>()
    .ToTable(
        "Cats",
        builder =>
        {
            builder.Property(cat => cat.Id).HasColumnName("CatId");
            builder.Property(cat => cat.Breed).HasColumnName("CatBreed");
        });

modelBuilder.Entity<Dog>()
    .ToTable(
        "Dogs",
        builder =>
        {
            builder.Property(dog => dog.Id).HasColumnName("DogId");
            builder.Property(dog => dog.Breed).HasColumnName("DogBreed");
        });

Relazioni molti-a-molti unidirezionali

EF7 supporta relazioni molti-a-molti in cui una o l'altra non dispone di una proprietà di navigazione. Si considerino Post , ad esempio, i tipi e Tag :

public class Post
{
    public int Id { get; set; }
    public string? Title { get; set; }
    public Blog Blog { get; set; } = null!;
    public List<Tag> Tags { get; } = new();
}
public class Tag
{
    public int Id { get; set; }
    public string TagName { get; set; } = null!;
}

Si noti che il Post tipo ha una proprietà di navigazione per un elenco di tag, ma il Tag tipo non dispone di una proprietà di navigazione per i post. In EF7, questo può comunque essere configurato come relazione molti-a-molti, consentendo l'uso dello stesso Tag oggetto per molti post diversi. Ad esempio:

modelBuilder
    .Entity<Post>()
    .HasMany(post => post.Tags)
    .WithMany();

In questo modo viene eseguito il mapping alla tabella di join appropriata:

CREATE TABLE [Tags] (
    [Id] int NOT NULL IDENTITY,
    [TagName] nvarchar(max) NOT NULL,
    CONSTRAINT [PK_Tags] PRIMARY KEY ([Id])
);

CREATE TABLE [Posts] (
    [Id] int NOT NULL IDENTITY,
    [Title] nvarchar(64) NULL,
    [BlogId] int NOT NULL,
    CONSTRAINT [PK_Posts] PRIMARY KEY ([Id]),
    CONSTRAINT [FK_Posts_Blogs_BlogId] FOREIGN KEY ([BlogId]) REFERENCES [Blogs] ([Id])
);

CREATE TABLE [PostTag] (
    [PostId] int NOT NULL,
    [TagsId] int NOT NULL,
    CONSTRAINT [PK_PostTag] PRIMARY KEY ([PostId], [TagsId]),
    CONSTRAINT [FK_PostTag_Posts_PostId] FOREIGN KEY ([PostId]) REFERENCES [Posts] ([Id]) ON DELETE CASCADE,
    CONSTRAINT [FK_PostTag_Tags_TagsId] FOREIGN KEY ([TagsId]) REFERENCES [Tags] ([Id]) ON DELETE CASCADE
);

E la relazione può essere usata come molti-a-molti nel modo normale. Ad esempio, inserendo alcuni post che condividono vari tag da un set comune:

var tags = new Tag[] { new() { TagName = "Tag1" }, new() { TagName = "Tag2" }, new() { TagName = "Tag2" }, };

await context.AddRangeAsync(new Blog { Posts =
{
    new Post { Tags = { tags[0], tags[1] } },
    new Post { Tags = { tags[1], tags[0], tags[2] } },
    new Post()
} });

await context.SaveChangesAsync();

Suddivisione di entità

La suddivisione delle entità esegue il mapping di un singolo tipo di entità a più tabelle. Si consideri, ad esempio, un database con tre tabelle che contengono i dati dei clienti:

  • Tabella Customers per le informazioni sui clienti
  • Tabella PhoneNumbers per il numero di telefono del cliente
  • Tabella Addresses per l'indirizzo del cliente

Ecco le definizioni per queste tabelle in SQL Server:

CREATE TABLE [Customers] (
    [Id] int NOT NULL IDENTITY,
    [Name] nvarchar(max) NOT NULL,
    CONSTRAINT [PK_Customers] PRIMARY KEY ([Id])
);
    
CREATE TABLE [PhoneNumbers] (
    [CustomerId] int NOT NULL,
    [PhoneNumber] nvarchar(max) NULL,
    CONSTRAINT [PK_PhoneNumbers] PRIMARY KEY ([CustomerId]),
    CONSTRAINT [FK_PhoneNumbers_Customers_CustomerId] FOREIGN KEY ([CustomerId]) REFERENCES [Customers] ([Id]) ON DELETE CASCADE
);

CREATE TABLE [Addresses] (
    [CustomerId] int NOT NULL,
    [Street] nvarchar(max) NOT NULL,
    [City] nvarchar(max) NOT NULL,
    [PostCode] nvarchar(max) NULL,
    [Country] nvarchar(max) NOT NULL,
    CONSTRAINT [PK_Addresses] PRIMARY KEY ([CustomerId]),
    CONSTRAINT [FK_Addresses_Customers_CustomerId] FOREIGN KEY ([CustomerId]) REFERENCES [Customers] ([Id]) ON DELETE CASCADE
);

Ognuna di queste tabelle viene in genere mappata al proprio tipo di entità, con relazioni tra i tipi. Tuttavia, se tutte e tre le tabelle vengono sempre usate insieme, può essere più conveniente eseguirne il mapping a un singolo tipo di entità. Ad esempio:

public class Customer
{
    public Customer(string name, string street, string city, string? postCode, string country)
    {
        Name = name;
        Street = street;
        City = city;
        PostCode = postCode;
        Country = country;
    }

    public int Id { get; set; }
    public string Name { get; set; }
    public string? PhoneNumber { get; set; }
    public string Street { get; set; }
    public string City { get; set; }
    public string? PostCode { get; set; }
    public string Country { get; set; }
}

Questo risultato viene ottenuto in EF7 chiamando SplitToTable per ogni divisione nel tipo di entità. Ad esempio, il codice seguente suddivide il Customer tipo di entità nelle Customerstabelle , PhoneNumberse Addresses illustrate in precedenza:

modelBuilder.Entity<Customer>(
    entityBuilder =>
    {
        entityBuilder
            .ToTable("Customers")
            .SplitToTable(
                "PhoneNumbers",
                tableBuilder =>
                {
                    tableBuilder.Property(customer => customer.Id).HasColumnName("CustomerId");
                    tableBuilder.Property(customer => customer.PhoneNumber);
                })
            .SplitToTable(
                "Addresses",
                tableBuilder =>
                {
                    tableBuilder.Property(customer => customer.Id).HasColumnName("CustomerId");
                    tableBuilder.Property(customer => customer.Street);
                    tableBuilder.Property(customer => customer.City);
                    tableBuilder.Property(customer => customer.PostCode);
                    tableBuilder.Property(customer => customer.Country);
                });
    });

Si noti anche che, se necessario, è possibile specificare nomi di colonna chiave primaria diversi per ognuna delle tabelle.

Stringhe UTF-8 di SQL Server

Le stringhe Unicode di SQL Server rappresentate dai nchar tipi di dati e nvarchar vengono archiviate come UTF-16. Inoltre, i char tipi di dati e varchar vengono usati per archiviare stringhe non Unicode con supporto per vari set di caratteri.

A partire da SQL Server 2019, i char tipi di dati e varchar possono essere usati per archiviare invece stringhe Unicode con codifica UTF-8 . L'oggetto viene ottenuto impostando una delle regole di confronto UTF-8. Ad esempio, il codice seguente configura una stringa UTF-8 di SQL Server a lunghezza variabile per la CommentText colonna:

modelBuilder
    .Entity<Comment>()
    .Property(comment => comment.CommentText)
    .HasColumnType("varchar(max)")
    .UseCollation("LATIN1_GENERAL_100_CI_AS_SC_UTF8");

Questa configurazione genera la definizione di colonna di SQL Server seguente:

CREATE TABLE [Comment] (
    [PostId] int NOT NULL,
    [CommentId] int NOT NULL,
    [CommentText] varchar(max) COLLATE LATIN1_GENERAL_100_CI_AS_SC_UTF8 NOT NULL,
    CONSTRAINT [PK_Comment] PRIMARY KEY ([PostId], [CommentId])
);

Le tabelle temporali supportano entità di proprietà

Il mapping delle tabelle temporali di SQL Server di EF Core è stato migliorato in EF7 per supportare la condivisione delle tabelle. In particolare, il mapping predefinito per le singole entità di proprietà usa la condivisione delle tabelle.

Si consideri, ad esempio, un tipo di Employee entità proprietario e il tipo di EmployeeInfoentità di proprietà :

public class Employee
{
    public Guid EmployeeId { get; set; }
    public string Name { get; set; } = null!;

    public EmployeeInfo Info { get; set; } = null!;
}

public class EmployeeInfo
{
    public string Position { get; set; } = null!;
    public string Department { get; set; } = null!;
    public string? Address { get; set; }
    public decimal? AnnualSalary { get; set; }
}

Se questi tipi vengono mappati alla stessa tabella, in EF7 è possibile creare una tabella temporale:

modelBuilder
    .Entity<Employee>()
    .ToTable(
        "Employees",
        tableBuilder =>
        {
            tableBuilder.IsTemporal();
            tableBuilder.Property<DateTime>("PeriodStart").HasColumnName("PeriodStart");
            tableBuilder.Property<DateTime>("PeriodEnd").HasColumnName("PeriodEnd");
        })
    .OwnsOne(
        employee => employee.Info,
        ownedBuilder => ownedBuilder.ToTable(
            "Employees",
            tableBuilder =>
            {
                tableBuilder.IsTemporal();
                tableBuilder.Property<DateTime>("PeriodStart").HasColumnName("PeriodStart");
                tableBuilder.Property<DateTime>("PeriodEnd").HasColumnName("PeriodEnd");
            }));

Nota

Per semplificare questa configurazione, è possibile tenere traccia del problema n. 29303. Votare per questo problema se si vuole vedere implementato.

Generazione di valori migliorata

EF7 include due miglioramenti significativi alla generazione automatica dei valori per le proprietà chiave.

Suggerimento

Il codice per gli esempi in questa sezione proviene da ValueGenerationSample.cs.

Generazione di valori per i tipi sorvegliati DDD

Nella progettazione basata su dominio (DDD), le "chiavi sorvegliate" possono migliorare la sicurezza dei tipi delle proprietà delle chiavi. Ciò si ottiene eseguendo il wrapping del tipo di chiave in un altro tipo specifico per l'uso della chiave. Ad esempio, il codice seguente definisce un ProductId tipo per i codici Product Key e un CategoryId tipo per le chiavi di categoria.

public readonly struct ProductId
{
    public ProductId(int value) => Value = value;
    public int Value { get; }
}

public readonly struct CategoryId
{
    public CategoryId(int value) => Value = value;
    public int Value { get; }
}

Questi vengono quindi usati nei Product tipi di entità e Category :

public class Product
{
    public Product(string name) => Name = name;
    public ProductId Id { get; set; }
    public string Name { get; set; }
    public CategoryId CategoryId { get; set; }
    public Category Category { get; set; } = null!;
}

public class Category
{
    public Category(string name) => Name = name;
    public CategoryId Id { get; set; }
    public string Name { get; set; }
    public List<Product> Products { get; } = new();
}

Ciò rende impossibile assegnare accidentalmente l'ID per una categoria a un prodotto o viceversa.

Avviso

Come per molti concetti DDD, questa maggiore sicurezza dei tipi è a scapito di una maggiore complessità del codice. Vale la pena considerare se, ad esempio, l'assegnazione di un ID prodotto a una categoria è qualcosa che potrebbe accadere. Mantenere le cose semplici può essere complessivamente più vantaggioso per la codebase.

I tipi di chiave sorvegliati illustrati di seguito esequisono int entrambi i valori chiave, ovvero i valori interi verranno usati nelle tabelle di database mappate. Questa operazione viene ottenuta definendo convertitori di valori per i tipi:

protected override void ConfigureConventions(ModelConfigurationBuilder configurationBuilder)
{
    configurationBuilder.Properties<ProductId>().HaveConversion<ProductIdConverter>();
    configurationBuilder.Properties<CategoryId>().HaveConversion<CategoryIdConverter>();
}

private class ProductIdConverter : ValueConverter<ProductId, int>
{
    public ProductIdConverter()
        : base(v => v.Value, v => new(v))
    {
    }
}

private class CategoryIdConverter : ValueConverter<CategoryId, int>
{
    public CategoryIdConverter()
        : base(v => v.Value, v => new(v))
    {
    }
}

Nota

Il codice qui usa tipi struct . Ciò significa che hanno una semantica di tipo valore appropriata da usare come chiavi. Se class invece vengono usati tipi, è necessario eseguire l'override della semantica di uguaglianza o specificare anche un operatore di confronto dei valori.

In EF7 i tipi di chiave basati sui convertitori di valori possono usare valori di chiave generati automaticamente, purché il tipo sottostante supporti questa opzione. Questa configurazione viene configurata nel modo normale usando ValueGeneratedOnAdd:

modelBuilder.Entity<Product>().Property(product => product.Id).ValueGeneratedOnAdd();
modelBuilder.Entity<Category>().Property(category => category.Id).ValueGeneratedOnAdd();

Per impostazione predefinita, si ottengono colonne IDENTITY quando usate con SQL Server:

CREATE TABLE [Categories] (
    [Id] int NOT NULL IDENTITY,
    [Name] nvarchar(max) NOT NULL,
    CONSTRAINT [PK_Categories] PRIMARY KEY ([Id]));

CREATE TABLE [Products] (
    [Id] int NOT NULL IDENTITY,
    [Name] nvarchar(max) NOT NULL,
    [CategoryId] int NOT NULL,
    CONSTRAINT [PK_Products] PRIMARY KEY ([Id]),
    CONSTRAINT [FK_Products_Categories_CategoryId] FOREIGN KEY ([CategoryId]) REFERENCES [Categories] ([Id]) ON DELETE CASCADE);

Che vengono usati nel modo normale per generare valori chiave durante l'inserimento di entità:

MERGE [Categories] USING (
VALUES (@p0, 0),
(@p1, 1)) AS i ([Name], _Position) ON 1=0
WHEN NOT MATCHED THEN
INSERT ([Name])
VALUES (i.[Name])
OUTPUT INSERTED.[Id], i._Position;

Generazione di chiavi basate su sequenza per SQL Server

EF Core supporta la generazione di valori chiave usando colonne di SQL Server IDENTITY o un modello Hi-Lo basato su blocchi di chiavi generate da una sequenza di database. EF7 introduce il supporto per una sequenza di database collegata al vincolo predefinito della colonna della chiave. Nel suo formato più semplice, è sufficiente indicare a EF Core di usare una sequenza per la proprietà chiave:

modelBuilder.Entity<Product>().Property(product => product.Id).UseSequence();

Ciò comporta la definizione di una sequenza nel database:

CREATE SEQUENCE [ProductSequence] START WITH 1 INCREMENT BY 1 NO MINVALUE NO MAXVALUE NO CYCLE;

Che viene quindi usato nel vincolo predefinito della colonna chiave:

CREATE TABLE [Products] (
    [Id] int NOT NULL DEFAULT (NEXT VALUE FOR [ProductSequence]),
    [Name] nvarchar(max) NOT NULL,
    [CategoryId] int NOT NULL,
    CONSTRAINT [PK_Products] PRIMARY KEY ([Id]),
    CONSTRAINT [FK_Products_Categories_CategoryId] FOREIGN KEY ([CategoryId]) REFERENCES [Categories] ([Id]) ON DELETE CASCADE);

Nota

Questa forma di generazione di chiavi viene usata per impostazione predefinita per le chiavi generate nelle gerarchie dei tipi di entità usando la strategia di mapping TPC.

Se necessario, alla sequenza può essere assegnato un nome e uno schema diversi. Ad esempio:

modelBuilder
    .Entity<Product>()
    .Property(product => product.Id)
    .UseSequence("ProductsSequence", "northwind");

Un'ulteriore configurazione della sequenza viene formata configurandola in modo esplicito nel modello. Ad esempio:

modelBuilder
    .HasSequence<int>("ProductsSequence", "northwind")
    .StartsAt(1000)
    .IncrementsBy(2);

Miglioramenti agli strumenti per le migrazioni

EF7 include due miglioramenti significativi quando si usano gli strumenti da riga di comando di EF Core Migrations.

UsareSqlServer e così via accettare null

È molto comune leggere un stringa di connessione da un file di configurazione e quindi passare tale stringa di connessione a UseSqlServer, UseSqliteo il metodo equivalente per un altro provider. Ad esempio:

services.AddDbContext<BloggingContext>(options =>
    options.UseSqlServer(Configuration.GetConnectionString("BloggingDatabase")));

È anche comune passare un stringa di connessione quando si applicano le migrazioni. Ad esempio:

dotnet ef database update --connection "Server=(localdb)\mssqllocaldb;Database=MyAppDb"

In alternativa, quando si usa un bundle migrations.

./bundle.exe --connection "Server=(localdb)\mssqllocaldb;Database=MyAppDb"

In questo caso, anche se la stringa di connessione letta dalla configurazione non viene usata, il codice di avvio dell'applicazione tenta comunque di leggerlo dalla configurazione e passarlo a UseSqlServer. Se la configurazione non è disponibile, questo comporta il passaggio di null a UseSqlServer. In EF7 questa opzione è consentita, purché il stringa di connessione venga impostato in un secondo momento, ad esempio passando --connection allo strumento da riga di comando.

Nota

Questa modifica è stata apportata per UseSqlServer e UseSqlite. Per altri provider, contattare il gestore del provider per apportare una modifica equivalente se non è ancora stata eseguita per tale provider.

Rilevare quando gli strumenti sono in esecuzione

EF Core esegue il codice dell'applicazione quando vengono usati i dotnet-ef comandi di o PowerShell . A volte può essere necessario rilevare questa situazione per impedire l'esecuzione di codice inappropriato in fase di progettazione. Ad esempio, il codice che applica automaticamente le migrazioni all'avvio dovrebbe probabilmente non eseguire questa operazione in fase di progettazione. In EF7 è possibile rilevare questo valore usando il EF.IsDesignTime flag :

if (!EF.IsDesignTime)
{
    await context.Database.MigrateAsync();
}

EF Core imposta su IsDesignTime true quando il codice dell'applicazione è in esecuzione per conto degli strumenti.

Miglioramenti delle prestazioni per i proxy

EF Core supporta proxy generati dinamicamente per il caricamento differita e il rilevamento delle modifiche. EF7 contiene due miglioramenti delle prestazioni quando si usano questi proxy:

  • I tipi di proxy vengono ora creati in modo differire. Ciò significa che il tempo di compilazione iniziale del modello quando si usano proxy può essere molto più veloce con EF7 rispetto a EF Core 6.0.
  • I proxy possono ora essere usati con i modelli compilati.

Ecco alcuni risultati delle prestazioni per un modello con 449 tipi di entità, 6390 proprietà e 720 relazioni.

Scenario metodo Media Error StdDev
EF Core 6.0 senza proxy TimeToFirstQuery 1,085 s 0,0083 s 0.0167 s
EF Core 6.0 con proxy di rilevamento delle modifiche TimeToFirstQuery 13.01 s 0.2040 s 0.4110 s
EF Core 7.0 senza proxy TimeToFirstQuery 1.442 s 0.0134 s 0.0272 s
EF Core 7.0 con proxy di rilevamento delle modifiche TimeToFirstQuery 1.446 s 0.0160 s 0.0323 s
EF Core 7.0 con proxy di rilevamento delle modifiche e modello compilato TimeToFirstQuery 0.162 s 0.0062 s 0.0125 s

In questo caso, quindi, un modello con proxy di rilevamento delle modifiche può essere pronto per eseguire la prima query 80 volte più veloce in EF7 rispetto a quanto possibile con EF Core 6.0.

Data binding di Windows Form di prima classe

Il team Windows Form ha apportato alcuni importanti miglioramenti all'esperienza di Progettazione di Visual Studio. Sono incluse nuove esperienze per il data binding che si integra bene con EF Core.

In breve, la nuova esperienza offre Visual Studio U.I. per la creazione di un ObjectDataSourceoggetto :

Scegliere il tipo di origine dati Categoria

Questo può quindi essere associato a un'istanza di EF Core DbSet con codice semplice:

public partial class MainForm : Form
{
    private ProductsContext? dbContext;

    public MainForm()
    {
        InitializeComponent();
    }

    protected override void OnLoad(EventArgs e)
    {
        base.OnLoad(e);

        this.dbContext = new ProductsContext();

        this.dbContext.Categories.Load();
        this.categoryBindingSource.DataSource = dbContext.Categories.Local.ToBindingList();
    }

    protected override void OnClosing(CancelEventArgs e)
    {
        base.OnClosing(e);

        this.dbContext?.Dispose();
        this.dbContext = null;
    }
}

Vedere Introduzione alle Windows Form per una procedura dettagliata completa e un'applicazione di esempio WinForms scaricabile.