Dela via


Nyheter i EF Core 8

EF Core 8.0 (EF8) släpptes i november 2023 och är en lts-version (Long Term Support). EF8 kommer att stödjas fram till den 10 november 2026.

Tip

Du kan köra och felsöka i exemplen genom att ladda ned exempelkoden från GitHub. Varje avsnitt länkar till källkoden som är specifik för det avsnittet.

EF8 kräver .NET 8 SDK för att bygga och kräver .NET 8 runtime för att köra. EF8 körs inte på tidigare .NET-versioner och körs inte på .NET Framework.

Värdeobjekt med komplexa typer

Objekt som sparats i databasen kan delas upp i tre breda kategorier:

  • Objekt som är ostrukturerade och innehåller ett enda värde. Till exempel int, Guid, string, IPAddress. Dessa kallas (något löst) "primitiva typer".
  • Objekt som är strukturerade för att innehålla flera värden och där objektets identitet definieras av ett nyckelvärde. Till exempel Blog, Post, Customer. Dessa kallas "entitetstyper".
  • Objekt som är strukturerade för att innehålla flera värden, men objektet har ingen nyckel som definierar identitet. Till exempel Address, Coordinate.

Före EF8 fanns det inget bra sätt att mappa den tredje typen av objekt. Ägda typer kan användas, men eftersom ägda typer faktiskt är entitetstyper har de semantik baserat på ett nyckelvärde, även när nyckelvärdet är dolt.

EF8 stöder nu "Komplexa typer" för att täcka den tredje typen av objekt. Komplexa typobjekt:

  • Identifieras inte eller spåras inte genom nyckelvärde.
  • Måste definieras som en del av en entitetstyp. (Med andra ord kan du inte ha en DbSet komplex typ.)
  • Kan vara antingen . NET-värdetyper eller referenstyper.
  • Instanser kan delas av flera egenskaper.

Enkelt exempel

Överväg till exempel en Address typ:

public class Address
{
    public required string Line1 { get; set; }
    public string? Line2 { get; set; }
    public required string City { get; set; }
    public required string Country { get; set; }
    public required string PostCode { get; set; }
}

Address används sedan på tre platser i en enkel kund/order-modell:

public class Customer
{
    public int Id { get; set; }
    public required string Name { get; set; }
    public required Address Address { get; set; }
    public List<Order> Orders { get; } = new();
}

public class Order
{
    public int Id { get; set; }
    public required string Contents { get; set; }
    public required Address ShippingAddress { get; set; }
    public required Address BillingAddress { get; set; }
    public Customer Customer { get; set; } = null!;
}

Nu ska vi skapa och spara en kund med deras adress:

var customer = new Customer
{
    Name = "Willow",
    Address = new() { Line1 = "Barking Gate", City = "Walpole St Peter", Country = "UK", PostCode = "PE14 7AV" }
};

context.Add(customer);
await context.SaveChangesAsync();

Detta resulterar i att följande rad infogas i databasen:

INSERT INTO [Customers] ([Name], [Address_City], [Address_Country], [Address_Line1], [Address_Line2], [Address_PostCode])
OUTPUT INSERTED.[Id]
VALUES (@p0, @p1, @p2, @p3, @p4, @p5);

Observera att de komplexa typerna inte får egna tabeller. I stället sparas de infogade i kolumner i Customers tabellen. Detta matchar tabelldelningsbeteendet för ägda typer.

Note

Vi planerar inte att tillåta att komplexa typer mappas till sin egen tabell. Men i en framtida version planerar vi att tillåta att den komplexa typen sparas som ett JSON-dokument i en enda kolumn. Rösta på Nummer 31252 om detta är viktigt för dig.

Anta nu att vi vill skicka en beställning till en kund och använda kundens adress som både standardfakturering och leveransadress. Det naturliga sättet att göra detta är att kopiera Address objektet från Customer till Order. Till exempel:

customer.Orders.Add(
    new Order { Contents = "Tesco Tasty Treats", BillingAddress = customer.Address, ShippingAddress = customer.Address, });

await context.SaveChangesAsync();

Med komplexa typer fungerar detta som förväntat och adressen infogas i Orders tabellen:

INSERT INTO [Orders] ([Contents], [CustomerId],
    [BillingAddress_City], [BillingAddress_Country], [BillingAddress_Line1], [BillingAddress_Line2], [BillingAddress_PostCode],
    [ShippingAddress_City], [ShippingAddress_Country], [ShippingAddress_Line1], [ShippingAddress_Line2], [ShippingAddress_PostCode])
OUTPUT INSERTED.[Id]
VALUES (@p0, @p1, @p2, @p3, @p4, @p5, @p6, @p7, @p8, @p9, @p10, @p11);

Hittills kanske du säger, "men jag skulle kunna göra detta med ägda typer!" Semantiken "entitetstyp" för ägda typer kommer dock snabbt i vägen. Om du till exempel kör koden ovan med ägda typer resulterar det i en mängd varningar och sedan ett fel:

warn: 8/20/2023 12:48:01.678 CoreEventId.DuplicateDependentEntityTypeInstanceWarning[10001] (Microsoft.EntityFrameworkCore.Update) 
      The same entity is being tracked as different entity types 'Order.BillingAddress#Address' and 'Customer.Address#Address' with defining navigations. If a property value changes, it will result in two store changes, which might not be the desired outcome.
warn: 8/20/2023 12:48:01.687 CoreEventId.DuplicateDependentEntityTypeInstanceWarning[10001] (Microsoft.EntityFrameworkCore.Update) 
      The same entity is being tracked as different entity types 'Order.ShippingAddress#Address' and 'Customer.Address#Address' with defining navigations. If a property value changes, it will result in two store changes, which might not be the desired outcome.
warn: 8/20/2023 12:48:01.687 CoreEventId.DuplicateDependentEntityTypeInstanceWarning[10001] (Microsoft.EntityFrameworkCore.Update)
      The same entity is being tracked as different entity types 'Order.ShippingAddress#Address' and 'Order.BillingAddress#Address' with defining navigations. If a property value changes, it will result in two store changes, which might not be the desired outcome.
fail: 8/20/2023 12:48:01.709 CoreEventId.SaveChangesFailed[10000] (Microsoft.EntityFrameworkCore.Update) 
      An exception occurred in the database while saving changes for context type 'NewInEfCore8.ComplexTypesSample+CustomerContext'.
      System.InvalidOperationException: Cannot save instance of 'Order.ShippingAddress#Address' because it is an owned entity without any reference to its owner. Owned entities can only be saved as part of an aggregate also including the owner entity.
         at Microsoft.EntityFrameworkCore.ChangeTracking.Internal.InternalEntityEntry.PrepareToSave()

Det beror på att en enskild instans av Address entitetstypen (med samma dolda nyckelvärde) används för tre olika entitetsinstanser. Å andra sidan är det tillåtet att dela samma instans mellan komplexa egenskaper, så koden fungerar som förväntat när du använder komplexa typer.

Konfiguration av komplexa typer

Komplexa typer måste konfigureras i modellen med antingen mappningsattribut eller genom att anropa ComplexProperty API:et i OnModelCreating. Komplexa typer identifieras inte av konventionen.

Typen kan till exempel Address konfigureras med hjälp av ComplexTypeAttribute:

[ComplexType]
public class Address
{
    public required string Line1 { get; set; }
    public string? Line2 { get; set; }
    public required string City { get; set; }
    public required string Country { get; set; }
    public required string PostCode { get; set; }
}

Eller i OnModelCreating:

protected override void OnModelCreating(ModelBuilder modelBuilder)
{
    modelBuilder.Entity<Customer>()
        .ComplexProperty(e => e.Address);

    modelBuilder.Entity<Order>(b =>
    {
        b.ComplexProperty(e => e.BillingAddress);
        b.ComplexProperty(e => e.ShippingAddress);
    });
}

Mutability

I exemplet ovan fick vi samma Address instans som användes på tre platser. Detta är tillåtet och orsakar inga problem för EF Core när du använder komplexa typer. Att dela instanser av samma referenstyp innebär dock att om ett egenskapsvärde på instansen ändras återspeglas ändringen i alla tre användningarna. Om vi till exempel följer ovanifrån ska vi ändra Line1 kundadressen och spara ändringarna:

customer.Address.Line1 = "Peacock Lodge";
await context.SaveChangesAsync();

Detta resulterar i följande uppdatering av databasen när du använder SQL Server:

UPDATE [Customers] SET [Address_Line1] = @p0
OUTPUT 1
WHERE [Id] = @p1;
UPDATE [Orders] SET [BillingAddress_Line1] = @p2, [ShippingAddress_Line1] = @p3
OUTPUT 1
WHERE [Id] = @p4;

Observera att alla tre Line1 kolumnerna har ändrats eftersom de alla delar samma instans. Det här är vanligtvis inte vad vi vill ha.

Tip

Om orderadresserna ska ändras automatiskt när kundadressen ändras kan du överväga att mappa adressen som en entitetstyp. Order och Customer kan sedan referera till samma adressinstans (som nu identifieras av en nyckel) via en navigeringsegenskap.

Ett bra sätt att hantera problem som detta är att göra typen oföränderlig. Denna oföränderlighet är ofta naturlig när en typ är en bra kandidat för att vara en komplex typ. Till exempel är det vanligtvis klokt att tillhandahålla ett komplext nytt Address objekt snarare än att bara mutera, låt oss säga, landet samtidigt som resten lämnas likadant.

Både referens- och värdetyper kan göras oföränderliga. Vi tittar på några exempel i följande avsnitt.

Referenstyper som komplexa typer

Oföränderlig klass

Vi använde en enkel, föränderlig class i exemplet ovan. För att förhindra problem med oavsiktlig mutation som beskrivs ovan kan vi göra klassen oföränderlig. Till exempel:

public class Address
{
    public Address(string line1, string? line2, string city, string country, string postCode)
    {
        Line1 = line1;
        Line2 = line2;
        City = city;
        Country = country;
        PostCode = postCode;
    }

    public string Line1 { get; }
    public string? Line2 { get; }
    public string City { get; }
    public string Country { get; }
    public string PostCode { get; }
}

Tip

Med C# 12 eller senare kan den här klassdefinitionen förenklas med hjälp av en primär konstruktor:

public class Address(string line1, string? line2, string city, string country, string postCode)
{
    public string Line1 { get; } = line1;
    public string? Line2 { get; } = line2;
    public string City { get; } = city;
    public string Country { get; } = country;
    public string PostCode { get; } = postCode;
}

Det går nu inte att ändra värdet på Line1 en befintlig adress. I stället måste vi skapa en ny instans med det ändrade värdet. Till exempel:

var currentAddress = customer.Address;
customer.Address = new Address(
    "Peacock Lodge", currentAddress.Line2, currentAddress.City, currentAddress.Country, currentAddress.PostCode);

await context.SaveChangesAsync();

Den här gången uppdaterar anropet endast SaveChangesAsync kundadressen:

UPDATE [Customers] SET [Address_Line1] = @p0
OUTPUT 1
WHERE [Id] = @p1;

Observera att även om adressobjektet är oföränderligt och hela objektet har ändrats, spårar EF fortfarande ändringar i de enskilda egenskaperna, så endast kolumnerna med ändrade värden uppdateras.

Oföränderlig post

C# 9 introducerade record-typer, vilket gör det enklare att skapa och använda oföränderliga objekt. Objektet kan till exempel Address göras till en registertyp.

public record Address
{
    public Address(string line1, string? line2, string city, string country, string postCode)
    {
        Line1 = line1;
        Line2 = line2;
        City = city;
        Country = country;
        PostCode = postCode;
    }

    public string Line1 { get; init; }
    public string? Line2 { get; init; }
    public string City { get; init; }
    public string Country { get; init; }
    public string PostCode { get; init; }
}

Tip

Den här postens definition kan förenklas med hjälp av en primär konstruktor:

public record Address(string Line1, string? Line2, string City, string Country, string PostCode);

För att ersätta det föränderliga objektet och anropa SaveChanges nu krävs mindre kod:

customer.Address = customer.Address with { Line1 = "Peacock Lodge" };

await context.SaveChangesAsync();

Värdetyper som komplexa typer

Föränderlig struktur

En enkel värdetyp som kan ändras kan användas som en komplex typ. Kan till exempel Address definieras som en struct i C#:

public struct Address
{
    public required string Line1 { get; set; }
    public string? Line2 { get; set; }
    public required string City { get; set; }
    public required string Country { get; set; }
    public required string PostCode { get; set; }
}

Om du tilldelar kundobjektet Address till leverans- och faktureringsegenskaperna Address får varje egenskap en kopia av Address, eftersom det är så här värdetyper fungerar. Detta innebär att ändring av Address på kunden inte ändrar Address instanserna för leverans eller fakturering, så ändringsbara strukturer har inte samma problem med instansdelning som uppstår med ändringsbara klasser.

Men mutable structs avråds vanligtvis i C#, så tänk mycket noga innan du använder dem.

Oföränderlig struct

Oföränderliga structs fungerar lika bra som komplexa typer, precis som oföränderliga klasser gör. Kan till exempel Address definieras så att den inte kan ändras:

public readonly struct Address(string line1, string? line2, string city, string country, string postCode)
{
    public string Line1 { get; } = line1;
    public string? Line2 { get; } = line2;
    public string City { get; } = city;
    public string Country { get; } = country;
    public string PostCode { get; } = postCode;
}

Koden för att ändra adressen ser nu likadan ut som när du använder oföränderlig klass:

var currentAddress = customer.Address;
customer.Address = new Address(
    "Peacock Lodge", currentAddress.Line2, currentAddress.City, currentAddress.Country, currentAddress.PostCode);

await context.SaveChangesAsync();

Oföränderlig strukturpost

C# 10 introducerade struct record typer, vilket gör det enkelt att skapa och arbeta med oföränderliga struct-poster som det är med oföränderliga klassposter. Vi kan till exempel definiera Address som en oföränderlig structpost:

public readonly record struct Address(string Line1, string? Line2, string City, string Country, string PostCode);

Koden för att ändra adressen ser nu likadan ut som när du använder oföränderlig klasspost:

customer.Address = customer.Address with { Line1 = "Peacock Lodge" };

await context.SaveChangesAsync();

Kapslade komplexa typer

En komplex typ kan innehålla egenskaper för andra komplexa typer. Låt oss till exempel använda vår Address komplexa typ ovanifrån tillsammans med en PhoneNumber komplex typ och kapsla dem båda i en annan komplex typ:

public record Address(string Line1, string? Line2, string City, string Country, string PostCode);

public record PhoneNumber(int CountryCode, long Number);

public record Contact
{
    public required Address Address { get; init; }
    public required PhoneNumber HomePhone { get; init; }
    public required PhoneNumber WorkPhone { get; init; }
    public required PhoneNumber MobilePhone { get; init; }
}

Vi använder immutabla records här, eftersom dessa passar bra för semantiken för våra komplexa typer, men kapsling av komplexa typer kan göras med valfri variant av .NET-typ.

Note

Vi använder inte någon primär konstruktor för Contact typen eftersom EF Core ännu inte stöder konstruktorinmatning av komplexa typvärden. Rösta på Nummer 31621 om detta är viktigt för dig.

Vi lägger till Contact som en egenskap för Customer:

public class Customer
{
    public int Id { get; set; }
    public required string Name { get; set; }
    public required Contact Contact { get; set; }
    public List<Order> Orders { get; } = new();
}

Och PhoneNumber som egenskaper för Order:

public class Order
{
    public int Id { get; set; }
    public required string Contents { get; set; }
    public required PhoneNumber ContactPhone { get; set; }
    public required Address ShippingAddress { get; set; }
    public required Address BillingAddress { get; set; }
    public Customer Customer { get; set; } = null!;
}

Konfiguration av kapslade komplexa typer kan återigen uppnås med hjälp av ComplexTypeAttribute:

[ComplexType]
public record Address(string Line1, string? Line2, string City, string Country, string PostCode);

[ComplexType]
public record PhoneNumber(int CountryCode, long Number);

[ComplexType]
public record Contact
{
    public required Address Address { get; init; }
    public required PhoneNumber HomePhone { get; init; }
    public required PhoneNumber WorkPhone { get; init; }
    public required PhoneNumber MobilePhone { get; init; }
}

Eller i OnModelCreating:

protected override void OnModelCreating(ModelBuilder modelBuilder)
{
    modelBuilder.Entity<Customer>(
        b =>
        {
            b.ComplexProperty(
                e => e.Contact,
                b =>
                {
                    b.ComplexProperty(e => e.Address);
                    b.ComplexProperty(e => e.HomePhone);
                    b.ComplexProperty(e => e.WorkPhone);
                    b.ComplexProperty(e => e.MobilePhone);
                });
        });

    modelBuilder.Entity<Order>(
        b =>
        {
            b.ComplexProperty(e => e.ContactPhone);
            b.ComplexProperty(e => e.BillingAddress);
            b.ComplexProperty(e => e.ShippingAddress);
        });
}

Queries

Egenskaper för komplexa typer av entitetstyper behandlas som andra icke-navigeringsegenskaper av entitetstypen. Det innebär att de alltid läses in när entitetstypen läses in. Detta gäller även för alla kapslade egenskaper av komplex typ. Till exempel fråga efter en kund:

var customer = await context.Customers.FirstAsync(e => e.Id == customerId);

Översätts till följande SQL när du använder SQL Server:

SELECT TOP(1) [c].[Id], [c].[Name], [c].[Contact_Address_City], [c].[Contact_Address_Country],
    [c].[Contact_Address_Line1], [c].[Contact_Address_Line2], [c].[Contact_Address_PostCode],
    [c].[Contact_HomePhone_CountryCode], [c].[Contact_HomePhone_Number], [c].[Contact_MobilePhone_CountryCode],
    [c].[Contact_MobilePhone_Number], [c].[Contact_WorkPhone_CountryCode], [c].[Contact_WorkPhone_Number]
FROM [Customers] AS [c]
WHERE [c].[Id] = @__customerId_0

Observera två saker från den här SQL:en:

  • Allt returneras för att fylla i kunden och alla kapslade Contact, Addressoch PhoneNumber komplexa typer.
  • Alla komplexa typvärden lagras som kolumner i tabellen för entitetstypen. Komplexa typer mappas aldrig till separata tabeller.

Projections

Komplexa typer kan projiceras från en fråga. Du kan till exempel välja bara leveransadressen från en beställning:

var shippingAddress = await context.Orders
    .Where(e => e.Id == orderId)
    .Select(e => e.ShippingAddress)
    .SingleAsync();

Detta översätts till följande när du använder SQL Server:

SELECT TOP(2) [o].[ShippingAddress_City], [o].[ShippingAddress_Country], [o].[ShippingAddress_Line1],
    [o].[ShippingAddress_Line2], [o].[ShippingAddress_PostCode]
FROM [Orders] AS [o]
WHERE [o].[Id] = @__orderId_0

Observera att projektioner av komplexa typer inte kan spåras, eftersom objekt av komplex typ inte har någon identitet att använda för spårning.

Används i predikaten

Medlemmar av komplexa typer kan användas i predikater. Du kan till exempel hitta alla beställningar som går till en viss stad:

var city = "Walpole St Peter";
var walpoleOrders = await context.Orders.Where(e => e.ShippingAddress.City == city).ToListAsync();

Vilket översätts till följande SQL på SQL Server:

SELECT [o].[Id], [o].[Contents], [o].[CustomerId], [o].[BillingAddress_City], [o].[BillingAddress_Country],
    [o].[BillingAddress_Line1], [o].[BillingAddress_Line2], [o].[BillingAddress_PostCode],
    [o].[ContactPhone_CountryCode], [o].[ContactPhone_Number], [o].[ShippingAddress_City],
    [o].[ShippingAddress_Country], [o].[ShippingAddress_Line1], [o].[ShippingAddress_Line2],
    [o].[ShippingAddress_PostCode]
FROM [Orders] AS [o]
WHERE [o].[ShippingAddress_City] = @__city_0

En fullständig instans av komplex typ kan också användas i predikat. Du kan till exempel hitta alla kunder med ett visst telefonnummer:

var phoneNumber = new PhoneNumber(44, 7777555777);
var customersWithNumber = await context.Customers
    .Where(
        e => e.Contact.MobilePhone == phoneNumber
             || e.Contact.WorkPhone == phoneNumber
             || e.Contact.HomePhone == phoneNumber)
    .ToListAsync();

Detta översätts till följande SQL när du använder SQL Server:

SELECT [c].[Id], [c].[Name], [c].[Contact_Address_City], [c].[Contact_Address_Country], [c].[Contact_Address_Line1],
     [c].[Contact_Address_Line2], [c].[Contact_Address_PostCode], [c].[Contact_HomePhone_CountryCode],
     [c].[Contact_HomePhone_Number], [c].[Contact_MobilePhone_CountryCode], [c].[Contact_MobilePhone_Number],
     [c].[Contact_WorkPhone_CountryCode], [c].[Contact_WorkPhone_Number]
FROM [Customers] AS [c]
WHERE ([c].[Contact_MobilePhone_CountryCode] = @__entity_equality_phoneNumber_0_CountryCode
    AND [c].[Contact_MobilePhone_Number] = @__entity_equality_phoneNumber_0_Number)
OR ([c].[Contact_WorkPhone_CountryCode] = @__entity_equality_phoneNumber_0_CountryCode
    AND [c].[Contact_WorkPhone_Number] = @__entity_equality_phoneNumber_0_Number)
OR ([c].[Contact_HomePhone_CountryCode] = @__entity_equality_phoneNumber_0_CountryCode
    AND [c].[Contact_HomePhone_Number] = @__entity_equality_phoneNumber_0_Number)

Observera att likheten utförs genom att expandera ut varje medlem av den komplexa typen. Detta överensstämmer med komplexa typer som inte har någon nyckel för identitet och därför är en komplex typinstans lika med en annan komplex typinstans om och endast om alla deras medlemmar är lika. Detta överensstämmer också med den jämlikhet som definieras av .NET för rekordtyper.

Manipulering av komplexa typvärden

EF8 ger åtkomst till spårningsinformation, till exempel aktuella och ursprungliga värden för komplexa typer och om ett egenskapsvärde har ändrats eller inte. DE KOMPLEXA API-typerna är ett tillägg till det ändringsspårnings-API som redan används för entitetstyper.

Metoderna av ComplexProperty för EntityEntry returnerar en inmatning för ett helt komplext objekt. Om du till exempel vill hämta det aktuella värdet för Order.BillingAddress:

var billingAddress = context.Entry(order)
    .ComplexProperty(e => e.BillingAddress)
    .CurrentValue;

Ett anrop till Property kan läggas till för att komma åt en egenskap av den komplexa typen. Om du till exempel vill hämta det aktuella värdet för bara faktureringspostkoden:

var postCode = context.Entry(order)
    .ComplexProperty(e => e.BillingAddress)
    .Property(e => e.PostCode)
    .CurrentValue;

Kapslade komplexa typer kommer åt med kapslade anrop till ComplexProperty. Till exempel för att hämta staden från den kapslade Address av Contact på en Customer:

var currentCity = context.Entry(customer)
    .ComplexProperty(e => e.Contact)
    .ComplexProperty(e => e.Address)
    .Property(e => e.City)
    .CurrentValue;

Andra metoder är tillgängliga för att läsa och ändra tillstånd. Kan till exempel PropertyEntry.IsModified användas för att ange en egenskap av en komplex typ som ändrad:

context.Entry(customer)
    .ComplexProperty(e => e.Contact)
    .ComplexProperty(e => e.Address)
    .Property(e => e.PostCode)
    .IsModified = true;

Aktuella begränsningar

Komplexa typer representerar en betydande investering i EF-stacken. Vi kunde inte få allt att fungera i den här versionen, men vi planerar att stänga några av luckorna i en framtida version. Se till att rösta (👍) om lämpliga GitHub-problem om det är viktigt för dig att åtgärda någon av dessa begränsningar.

Komplexa typbegränsningar i EF8 är:

Primitiva samlingar

En beständig fråga när du använder relationsdatabaser är vad du ska göra med samlingar av primitiva typer. det vill: listor eller matriser med heltal, datum/tider, strängar och så vidare. Om du använder PostgreSQL är det enkelt att lagra dessa saker med postgreSQL:s inbyggda matristyp. För andra databaser finns det två vanliga metoder:

  • Skapa en tabell med en kolumn för det primitiva typvärdet och en annan kolumn för att fungera som en sekundärnyckel som länkar varje värde till dess ägare av samlingen.
  • Serialisera den primitiva samlingen till någon kolumntyp som hanteras av databasen, till exempel serialisera till och från en sträng.

Det första alternativet har fördelar i många situationer – vi tar en snabb titt på det i slutet av det här avsnittet. Men det är inte en naturlig representation av data i modellen, och om det du verkligen har är en samling av en primitiv typ kan det andra alternativet vara effektivare.

Från och med förhandsversion 4 innehåller EF8 nu inbyggt stöd för det andra alternativet, med JSON som serialiseringsformat. JSON fungerar bra för detta eftersom moderna relationsdatabaser innehåller inbyggda mekanismer för att fråga efter och manipulera JSON, så att JSON-kolumnen i praktiken kan behandlas som en tabell när det behövs, utan att behöva skapa tabellen. Samma mekanismer gör att JSON kan skickas i parametrar och sedan användas på liknande sätt som tabellvärdesparametrar i frågor – mer om detta senare.

Tip

Koden som visas här kommer från PrimitiveCollectionsSample.cs.

Egenskaper för primitiv samling

EF Core kan mappa alla IEnumerable<T> egenskaper, där T är en primitiv typ, till en JSON-kolumn i databasen. Detta görs enligt konvention för publika parametrar som har både en getter och en setter. Till exempel mappas alla egenskaper i följande entitetstyp till JSON-kolumner efter konvention:

public class PrimitiveCollections
{
    public IEnumerable<int> Ints { get; set; }
    public ICollection<string> Strings { get; set; }
    public IList<DateOnly> Dates { get; set; }
    public uint[] UnsignedInts { get; set; }
    public List<bool> Booleans { get; set; }
    public List<Uri> Urls { get; set; }
}

Note

Vad menar vi med "primitiv typ" i det här sammanhanget? I princip något som databasprovidern vet hur man mappar, med hjälp av någon form av värdekonvertering om det behövs. I entitetstypen ovan hanteras till exempel typerna int, string, DateTimeDateOnlyoch och bool alla utan konvertering av databasprovidern. SQL Server har inte inbyggt stöd för osignerade ints eller URI:er, men uint de Uri behandlas fortfarande som primitiva typer eftersom det finns inbyggda värdekonverterare för dessa typer.

Som standard använder EF Core en obehindrat Unicode-strängkolumntyp för att lagra JSON, eftersom detta skyddar mot dataförlust med stora samlingar. I vissa databassystem, till exempel SQL Server, kan det dock förbättra prestanda genom att ange en maximal längd för strängen. Detta, tillsammans med andra kolumnkonfigurationer, kan göras på vanligt sätt. Till exempel:

modelBuilder
    .Entity<PrimitiveCollections>()
    .Property(e => e.Booleans)
    .HasMaxLength(1024)
    .IsUnicode(false);

Eller så använder du mappningsattribut:

[MaxLength(2500)]
[Unicode(false)]
public uint[] UnsignedInts { get; set; }

En standardkolumnkonfiguration kan användas för alla egenskaper av en viss typ med hjälp av förkonvent modellkonfiguration. Till exempel:

protected override void ConfigureConventions(ModelConfigurationBuilder configurationBuilder)
{
    configurationBuilder
        .Properties<List<DateOnly>>()
        .AreUnicode(false)
        .HaveMaxLength(4000);
}

Sökfrågor med primitiva samlingar

Nu ska vi titta på några av de frågor som använder samlingar av primitiva typer. För detta behöver vi en enkel modell med två entitetstyper. Den första representerar ett brittiskt offentligt hus, eller "pub":

public class Pub
{
    public Pub(string name, string[] beers)
    {
        Name = name;
        Beers = beers;
    }

    public int Id { get; set; }
    public string Name { get; set; }
    public string[] Beers { get; set; }
    public List<DateOnly> DaysVisited { get; private set; } = new();
}

Typen Pub innehåller två primitiva samlingar:

  • Beers är en matris med strängar som representerar ölmärkena som finns på puben.
  • DaysVisited är en lista över datum då puben besöktes.

Tip

I ett riktigt program skulle det förmodligen vara mer meningsfullt att skapa en entitetstyp för öl och ha en tabell för öl. Vi visar en primitiv samling här för att illustrera hur de fungerar. Men kom ihåg att bara för att du kan modellera något som en primitiv samling betyder det inte att du nödvändigtvis borde göra det.

Den andra entitetstypen representerar en hundpromenad på den brittiska landsbygden:

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

    public int Id { get; set; }
    public string Name { get; set; }
    public Terrain Terrain { get; set; }
    public List<DateOnly> DaysVisited { get; private set; } = new();
    public Pub ClosestPub { get; set; } = null!;
}

public enum Terrain
{
    Forest,
    River,
    Hills,
    Village,
    Park,
    Beach,
}

Liksom Pub, DogWalk innehåller också en samling av de datum som besökts, och en länk till närmaste pub eftersom, du vet, ibland hunden behöver ett fat öl efter en lång promenad.

Med den här modellen är den första frågan vi ska ställa en enkel Contains fråga för att hitta alla vandringar med en av flera olika terrängtyper.

var terrains = new[] { Terrain.River, Terrain.Beach, Terrain.Park };
var walksWithTerrain = await context.Walks
    .Where(e => terrains.Contains(e.Terrain))
    .Select(e => e.Name)
    .ToListAsync();

Detta översätts redan av aktuella versioner av EF Core genom att ange de värden som ska sökas efter. När du till exempel använder SQL Server:

SELECT [w].[Name]
FROM [Walks] AS [w]
WHERE [w].[Terrain] IN (1, 5, 4)

Den här strategin fungerar dock inte bra med cachelagring av databasfrågor. Se Att tillkännage EF8 Preview 4 på .NET-bloggen för en diskussion om problemet.

Important

Inlining av värden här görs på ett sådant sätt att det inte finns någon risk för en SQL-inmatningsattack. Ändringen för att använda JSON som beskrivs nedan handlar om prestanda och ingenting som har att göra med säkerhet.

För EF Core 8 är standardinställningen nu att skicka listan över terränger som en enda parameter som innehåller en JSON-samling. Till exempel:

@__terrains_0='[1,5,4]'

Frågan använder OpenJson sedan på SQL Server:

SELECT [w].[Name]
FROM [Walks] AS [w]
WHERE EXISTS (
    SELECT 1
    FROM OpenJson(@__terrains_0) AS [t]
    WHERE CAST([t].[value] AS int) = [w].[Terrain])

Eller json_each på SQLite:

SELECT "w"."Name"
FROM "Walks" AS "w"
WHERE EXISTS (
    SELECT 1
    FROM json_each(@__terrains_0) AS "t"
    WHERE "t"."value" = "w"."Terrain")

Note

OpenJson är endast tillgängligt på SQL Server 2016 (kompatibilitetsnivå 130) och senare. Du kan berätta för SQL Server att du använder en äldre version genom att konfigurera kompatibilitetsnivån som en del av UseSqlServer. Till exempel:

protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
    => optionsBuilder
        .UseSqlServer(
            @"Data Source=(LocalDb)\MSSQLLocalDB;Database=AllTogetherNow",
            sqlServerOptionsBuilder => sqlServerOptionsBuilder.UseCompatibilityLevel(120));

Nu ska vi prova en annan typ av Contains fråga. I det här fallet letar vi efter ett värde för parametersamlingen i kolumnen. Till exempel alla pubar som har Heineken:

var beer = "Heineken";
var pubsWithHeineken = await context.Pubs
    .Where(e => e.Beers.Contains(beer))
    .Select(e => e.Name)
    .ToListAsync();

Den befintliga dokumentationen från Nyheter i EF7 innehåller detaljerad information om JSON-mappning, frågor och uppdateringar. Den här dokumentationen gäller nu även för SQLite.

SELECT [p].[Name]
FROM [Pubs] AS [p]
WHERE EXISTS (
    SELECT 1
    FROM OpenJson([p].[Beers]) AS [b]
    WHERE [b].[value] = @__beer_0)

OpenJson används nu för att extrahera värden från JSON-kolumnen så att varje värde kan matchas med den skickade parametern.

Vi kan kombinera användningen av OpenJson på parametern med OpenJson i kolumnen. Om du till exempel vill hitta pubar som har någon av flera lager:

var beers = new[] { "Carling", "Heineken", "Stella Artois", "Carlsberg" };
var pubsWithLager = await context.Pubs
    .Where(e => beers.Any(b => e.Beers.Contains(b)))
    .Select(e => e.Name)
    .ToListAsync();

Detta innebär följande på SQL Server:

SELECT [p].[Name]
FROM [Pubs] AS [p]
WHERE EXISTS (
    SELECT 1
    FROM OpenJson(@__beers_0) AS [b]
    WHERE EXISTS (
        SELECT 1
        FROM OpenJson([p].[Beers]) AS [b0]
        WHERE [b0].[value] = [b].[value] OR ([b0].[value] IS NULL AND [b].[value] IS NULL)))

Parametervärdet @__beers_0 här är ["Carling","Heineken","Stella Artois","Carlsberg"].

Nu ska vi titta på en fråga som använder kolumnen som innehåller en samling datum. För att till exempel hitta pubar som besökts i år:

var thisYear = DateTime.Now.Year;
var pubsVisitedThisYear = await context.Pubs
    .Where(e => e.DaysVisited.Any(v => v.Year == thisYear))
    .Select(e => e.Name)
    .ToListAsync();

Detta innebär följande på SQL Server:

SELECT [p].[Name]
FROM [Pubs] AS [p]
WHERE EXISTS (
    SELECT 1
    FROM OpenJson([p].[DaysVisited]) AS [d]
    WHERE DATEPART(year, CAST([d].[value] AS date)) = @__thisYear_0)

Observera att frågan använder den datumspecifika funktionen DATEPART här eftersom EF vet att den primitiva samlingen innehåller datum. Det kanske inte verkar så, men det här är faktiskt väldigt viktigt. Eftersom EF vet vad som finns i samlingen kan den generera lämplig SQL för att använda de inskrivna värdena med parametrar, funktioner, andra kolumner osv.

Nu ska vi använda datumsamlingen igen, den här gången för att sortera efter typ- och projektvärden som extraherats från samlingen. Låt oss till exempel lista pubar i den ordning som de först besöktes, och med det första och sista datumet som varje pub besöktes:

var pubsVisitedInOrder = await context.Pubs
    .Select(e => new
    {
        e.Name,
        FirstVisited = e.DaysVisited.OrderBy(v => v).First(),
        LastVisited = e.DaysVisited.OrderByDescending(v => v).First(),
    })
    .OrderBy(p => p.FirstVisited)
    .ToListAsync();

Detta innebär följande på SQL Server:

SELECT [p].[Name], (
    SELECT TOP(1) CAST([d0].[value] AS date)
    FROM OpenJson([p].[DaysVisited]) AS [d0]
    ORDER BY CAST([d0].[value] AS date)) AS [FirstVisited], (
    SELECT TOP(1) CAST([d1].[value] AS date)
    FROM OpenJson([p].[DaysVisited]) AS [d1]
    ORDER BY CAST([d1].[value] AS date) DESC) AS [LastVisited]
FROM [Pubs] AS [p]
ORDER BY (
    SELECT TOP(1) CAST([d].[value] AS date)
    FROM OpenJson([p].[DaysVisited]) AS [d]
    ORDER BY CAST([d].[value] AS date))

Och slutligen, hur ofta besöker vi den närmaste puben när vi tar hunden på en promenad? Låt oss ta reda på följande:

var walksWithADrink = await context.Walks.Select(
    w => new
    {
        WalkName = w.Name,
        PubName = w.ClosestPub.Name,
        Count = w.DaysVisited.Count(v => w.ClosestPub.DaysVisited.Contains(v)),
        TotalCount = w.DaysVisited.Count
    }).ToListAsync();

Detta innebär följande på SQL Server:

SELECT [w].[Name] AS [WalkName], [p].[Name] AS [PubName], (
    SELECT COUNT(*)
    FROM OpenJson([w].[DaysVisited]) AS [d]
    WHERE EXISTS (
        SELECT 1
        FROM OpenJson([p].[DaysVisited]) AS [d0]
        WHERE CAST([d0].[value] AS date) = CAST([d].[value] AS date) OR ([d0].[value] IS NULL AND [d].[value] IS NULL))) AS [Count], (
    SELECT COUNT(*)
    FROM OpenJson([w].[DaysVisited]) AS [d1]) AS [TotalCount]
FROM [Walks] AS [w]
INNER JOIN [Pubs] AS [p] ON [w].[ClosestPubId] = [p].[Id]

Och visar följande data:

The Prince of Wales Feathers was visited 5 times in 8 "Ailsworth to Nene" walks.
The Prince of Wales Feathers was visited 6 times in 9 "Caster Hanglands" walks.
The Royal Oak was visited 6 times in 8 "Ferry Meadows" walks.
The White Swan was visited 7 times in 9 "Woodnewton" walks.
The Eltisley was visited 6 times in 8 "Eltisley" walks.
Farr Bay Inn was visited 7 times in 11 "Farr Beach" walks.
Farr Bay Inn was visited 7 times in 9 "Newlands" walks.

Ser ut som öl och hundpromenader är en vinnande kombination!

Primitiva samlingar i JSON-dokument

I alla exempel ovan innehåller kolumnen för primitiv samling JSON. Detta är dock inte samma sak som att mappa en ägd entitetstyp till en kolumn som innehåller ett JSON-dokument som introducerades i EF7. Men vad händer om själva JSON-dokumentet innehåller en primitiv samling? Tja, alla frågor ovan fungerar fortfarande på samma sätt! Exempelvis, tänk att vi flyttar de besökta dagarna data till en ägd typ Visits som mappats till ett JSON-dokument.

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

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

public class Visits
{
    public string? LocationTag { get; set; }
    public List<DateOnly> DaysVisited { get; set; } = null!;
}

Tip

Koden som visas här kommer från PrimitiveCollectionsInJsonSample.cs.

Vi kan nu köra en variant av vår slutliga fråga som den här gången extraherar data från JSON-dokumentet, inklusive frågor till de primitiva samlingarna som finns i dokumentet:

var walksWithADrink = await context.Walks.Select(
    w => new
    {
        WalkName = w.Name,
        PubName = w.ClosestPub.Name,
        WalkLocationTag = w.Visits.LocationTag,
        PubLocationTag = w.ClosestPub.Visits.LocationTag,
        Count = w.Visits.DaysVisited.Count(v => w.ClosestPub.Visits.DaysVisited.Contains(v)),
        TotalCount = w.Visits.DaysVisited.Count
    }).ToListAsync();

Detta innebär följande på SQL Server:

SELECT [w].[Name] AS [WalkName], [p].[Name] AS [PubName], JSON_VALUE([w].[Visits], '$.LocationTag') AS [WalkLocationTag], JSON_VALUE([p].[Visits], '$.LocationTag') AS [PubLocationTag], (
    SELECT COUNT(*)
    FROM OpenJson(JSON_VALUE([w].[Visits], '$.DaysVisited')) AS [d]
    WHERE EXISTS (
        SELECT 1
        FROM OpenJson(JSON_VALUE([p].[Visits], '$.DaysVisited')) AS [d0]
        WHERE CAST([d0].[value] AS date) = CAST([d].[value] AS date) OR ([d0].[value] IS NULL AND [d].[value] IS NULL))) AS [Count], (
    SELECT COUNT(*)
    FROM OpenJson(JSON_VALUE([w].[Visits], '$.DaysVisited')) AS [d1]) AS [TotalCount]
FROM [Walks] AS [w]
INNER JOIN [Pubs] AS [p] ON [w].[ClosestPubId] = [p].[Id]

Och till en liknande fråga när du använder SQLite:

SELECT "w"."Name" AS "WalkName", "p"."Name" AS "PubName", "w"."Visits" ->> 'LocationTag' AS "WalkLocationTag", "p"."Visits" ->> 'LocationTag' AS "PubLocationTag", (
    SELECT COUNT(*)
    FROM json_each("w"."Visits" ->> 'DaysVisited') AS "d"
    WHERE EXISTS (
        SELECT 1
        FROM json_each("p"."Visits" ->> 'DaysVisited') AS "d0"
        WHERE "d0"."value" = "d"."value")) AS "Count", json_array_length("w"."Visits" ->> 'DaysVisited') AS "TotalCount"
FROM "Walks" AS "w"
INNER JOIN "Pubs" AS "p" ON "w"."ClosestPubId" = "p"."Id"

Tip

Observera att på SQLite EF Core nu använder operatorn ->> , vilket resulterar i frågor som både är lättare att läsa och ofta mer högpresterande.

Mappa primitiva samlingar till en tabell

Vi nämnde ovan att ett annat alternativ för primitiva samlingar är att mappa dem till en annan tabell. Förstklassigt stöd för detta spåras av Problem #25163; se till att rösta för denna fråga om det är viktigt för dig. Tills detta implementeras är den bästa metoden att skapa en omslutningstyp för primitiven. Låt oss till exempel skapa en typ för Beer:

[Owned]
public class Beer
{
    public Beer(string name)
    {
        Name = name;
    }

    public string Name { get; private set; }
}

Observera att typen helt enkelt omsluter det primitiva värdet – den har ingen primärnyckel eller några sekundärnycklar definierade. Den här typen kan sedan användas i Pub klassen:

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

    public int Id { get; set; }
    public string Name { get; set; }
    public List<Beer> Beers { get; set; } = new();
    public List<DateOnly> DaysVisited { get; private set; } = new();
}

EF skapar nu en Beer tabell som syntetiserar primärnyckel och sekundärnyckelkolumner tillbaka till Pubs tabellen. Till exempel på SQL Server:

CREATE TABLE [Beer] (
    [PubId] int NOT NULL,
    [Id] int NOT NULL IDENTITY,
    [Name] nvarchar(max) NOT NULL,
    CONSTRAINT [PK_Beer] PRIMARY KEY ([PubId], [Id]),
    CONSTRAINT [FK_Beer_Pubs_PubId] FOREIGN KEY ([PubId]) REFERENCES [Pubs] ([Id]) ON DELETE CASCADE

Förbättringar av JSON-kolumnmappning

EF8 innehåller förbättringar av stöd för JSON-kolumnmappning som introducerades i EF7.

Tip

Koden som visas här kommer från JsonColumnsSample.cs.

Översätta elementåtkomst till JSON-matriser

EF8 stöder indexering i JSON-matriser vid körning av frågor. Följande fråga kontrollerar till exempel om de två första uppdateringarna gjordes före ett visst datum.

var cutoff = DateOnly.FromDateTime(DateTime.UtcNow - TimeSpan.FromDays(365));
var updatedPosts = await context.Posts
    .Where(
        p => p.Metadata!.Updates[0].UpdatedOn < cutoff
             && p.Metadata!.Updates[1].UpdatedOn < cutoff)
    .ToListAsync();

Detta översätts till följande SQL när du använder SQL Server:

SELECT [p].[Id], [p].[Archived], [p].[AuthorId], [p].[BlogId], [p].[Content], [p].[Discriminator], [p].[PublishedOn], [p].[Title], [p].[PromoText], [p].[Metadata]
FROM [Posts] AS [p]
WHERE CAST(JSON_VALUE([p].[Metadata],'$.Updates[0].UpdatedOn') AS date) < @__cutoff_0
  AND CAST(JSON_VALUE([p].[Metadata],'$.Updates[1].UpdatedOn') AS date) < @__cutoff_0

Note

Den här frågan lyckas även om ett visst inlägg inte har några uppdateringar eller bara har en enda uppdatering. I ett sådant fall returnerar JSON_VALUENULL och villkoret uppfylls inte.

Indexering i JSON-matriser kan också användas för att projicera element från en matris till slutresultatet. Följande fråga projicerar UpdatedOn till exempel datumet för de första och andra uppdateringarna av varje inlägg.

var postsAndRecentUpdatesNullable = await context.Posts
    .Select(p => new
    {
        p.Title,
        LatestUpdate = (DateOnly?)p.Metadata!.Updates[0].UpdatedOn,
        SecondLatestUpdate = (DateOnly?)p.Metadata.Updates[1].UpdatedOn
    })
    .ToListAsync();

Detta översätts till följande SQL när du använder SQL Server:

SELECT [p].[Title],
       CAST(JSON_VALUE([p].[Metadata],'$.Updates[0].UpdatedOn') AS date) AS [LatestUpdate],
       CAST(JSON_VALUE([p].[Metadata],'$.Updates[1].UpdatedOn') AS date) AS [SecondLatestUpdate]
FROM [Posts] AS [p]

Som nämnts ovan JSON_VALUE returnerar null om elementet i matrisen inte finns. Detta hanteras i sökfrågan genom att det beräknade värdet kastas till en nullbar DateOnly. Ett alternativ till att konvertera värdet är att filtrera frågeresultatet så att JSON_VALUE aldrig returnerar null. Till exempel:

var postsAndRecentUpdates = await context.Posts
    .Where(p => p.Metadata!.Updates[0].UpdatedOn != null
                && p.Metadata!.Updates[1].UpdatedOn != null)
    .Select(p => new
    {
        p.Title,
        LatestUpdate = p.Metadata!.Updates[0].UpdatedOn,
        SecondLatestUpdate = p.Metadata.Updates[1].UpdatedOn
    })
    .ToListAsync();

Detta översätts till följande SQL när du använder SQL Server:

SELECT [p].[Title],
       CAST(JSON_VALUE([p].[Metadata],'$.Updates[0].UpdatedOn') AS date) AS [LatestUpdate],
       CAST(JSON_VALUE([p].[Metadata],'$.Updates[1].UpdatedOn') AS date) AS [SecondLatestUpdate]
FROM [Posts] AS [p]
      WHERE (CAST(JSON_VALUE([p].[Metadata],'$.Updates[0].UpdatedOn') AS date) IS NOT NULL)
        AND (CAST(JSON_VALUE([p].[Metadata],'$.Updates[1].UpdatedOn') AS date) IS NOT NULL)

Översätta frågor till inbäddade samlingar

EF8 stöder frågor mot samlingar av både primitiva (beskrivs ovan) och icke-primitiva typer som är inbäddade i JSON-dokumentet. Följande fråga returnerar till exempel alla inlägg med en godtycklig lista med söktermer:

var searchTerms = new[] { "Search #2", "Search #3", "Search #5", "Search #8", "Search #13", "Search #21", "Search #34" };

var postsWithSearchTerms = await context.Posts
    .Where(post => post.Metadata!.TopSearches.Any(s => searchTerms.Contains(s.Term)))
    .ToListAsync();

Detta översätts till följande SQL när du använder SQL Server:

SELECT [p].[Id], [p].[Archived], [p].[AuthorId], [p].[BlogId], [p].[Content], [p].[Discriminator], [p].[PublishedOn], [p].[Title], [p].[PromoText], [p].[Metadata]
FROM [Posts] AS [p]
WHERE EXISTS (
    SELECT 1
    FROM OPENJSON([p].[Metadata], '$.TopSearches') WITH (
        [Count] int '$.Count',
        [Term] nvarchar(max) '$.Term'
    ) AS [t]
    WHERE EXISTS (
        SELECT 1
        FROM OPENJSON(@__searchTerms_0) WITH ([value] nvarchar(max) '$') AS [s]
        WHERE [s].[value] = [t].[Term]))

JSON-kolumner för SQLite

EF7 introducerade stöd för mappning till JSON-kolumner när du använder Azure SQL/SQL Server. EF8 utökar det här stödet till SQLite-databaser. När det gäller SQL Server-stöd omfattar detta:

  • Mappning av aggregeringar som skapats från .NET-typer till JSON-dokument som lagras i SQLite-kolumner
  • Frågor till JSON-kolumner, till exempel filtrering och sortering efter elementen i dokumenten
  • Frågor som extraherar element från JSON-dokumentet till resultatet
  • Uppdatera och spara ändringar i JSON-dokument

Den befintliga dokumentationen från Nyheter i EF7 innehåller detaljerad information om JSON-mappning, frågor och uppdateringar. Den här dokumentationen gäller nu även för SQLite.

Tip

Koden som visas i EF7-dokumentationen har uppdaterats för att även köras på SQLite finns i JsonColumnsSample.cs.

Frågor till JSON-kolumner

Frågor till JSON-kolumner på SQLite använder json_extract funktionen. Till exempel "författare i Chigley"-frågan från den ovan refererade dokumentationen:

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

Översätts till följande SQL när du använder SQLite:

SELECT "a"."Id", "a"."Name", "a"."Contact"
FROM "Authors" AS "a"
WHERE json_extract("a"."Contact", '$.Address.City') = 'Chigley'

Uppdatera JSON-kolumner

För uppdateringar använder EF json_set-funktionen på SQLite. När du till exempel uppdaterar en enskild egenskap i ett dokument:

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

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

await context.SaveChangesAsync();

EF genererar följande parametrar:

info: 3/10/2023 10:51:33.127 RelationalEventId.CommandExecuted[20101] (Microsoft.EntityFrameworkCore.Database.Command)
      Executed DbCommand (0ms) [Parameters=[@p0='["United Kingdom"]' (Nullable = false) (Size = 18), @p1='4'], CommandType='Text', CommandTimeout='30']

Som använder json_set funktionen på SQLite:

UPDATE "Authors" SET "Contact" = json_set("Contact", '$.Address.Country', json_extract(@p0, '$[0]'))
WHERE "Id" = @p1
RETURNING 1;

HierarchyId i .NET och EF Core

Azure SQL och SQL Server har en särskild datatyp som kallas hierarchyid som används för att lagra hierarkiska data. I det här fallet innebär "hierarkiska data" i princip data som utgör en trädstruktur, där varje objekt kan ha en överordnad och/eller underordnade objekt. Exempel på sådana data är:

  • En organisationsstruktur
  • Ett filsystem
  • En uppsättning aktiviteter i ett projekt
  • En taxonomi av språktermer
  • Ett diagram över länkar mellan webbsidor

Databasen kan sedan köra frågor mot dessa data med hjälp av dess hierarkiska struktur. En fråga kan till exempel hitta överordnade och beroenden av angivna objekt eller hitta alla objekt på ett visst djup i hierarkin.

Stöd i .NET och EF Core

Officiellt stöd för SQL Server-typen hierarchyid har nyligen kommit till moderna .NET-plattformar. Det här stödet är i form av NuGet-paketet Microsoft.SqlServer.Types , som innehåller SQL Server-specifika typer på låg nivå. I det här fallet kallas lågnivåtypen SqlHierarchyId.

På nästa nivå har ett nytt Microsoft.EntityFrameworkCore.SqlServer.Abstractions-paketet introducerats, som innehåller en HierarchyId på högre nivå som är avsedd att användas i entitetstyper.

Tip

Den HierarchyId typen är mer idiomatisk för normerna för .NET än SqlHierarchyId, som i stället modelleras efter hur .NET Framework-typer finns i SQL Server-databasmotorn. HierarchyId är utformat för att fungera med EF Core, men det kan också användas utanför EF Core i andra program. Det Microsoft.EntityFrameworkCore.SqlServer.Abstractions paketet refererar inte till några andra paket och har därför minimal påverkan på distribuerad programstorlek och beroenden.

Användning av HierarchyId för EF Core-funktioner som frågor och uppdateringar kräver paketet Microsoft.EntityFrameworkCore.SqlServer.HierarchyId. Det här paketet innehåller Microsoft.EntityFrameworkCore.SqlServer.Abstractions och Microsoft.SqlServer.Types som transitiva beroenden, och är därför ofta det enda paket som behövs. När paketet har installerats aktiveras användningen av HierarchyId genom att anropa UseHierarchyId som en del av programmets anrop till UseSqlServer. Till exempel:

options.UseSqlServer(
    connectionString,
    x => x.UseHierarchyId());

Note

Inofficiellt stöd för hierarchyid i EF Core har varit tillgängligt i många år via EntityFrameworkCore.SqlServer.HierarchyId-paketet . Det här paketet har upprätthållits som ett samarbete mellan communityn och EF-teamet. Nu när det finns officiellt stöd för hierarchyid i .NET, utgör koden från det här community-paketet, med tillstånd från de ursprungliga bidragsgivarna, grunden för det officiella paketet som beskrivs här. Stort tack till alla inblandade genom åren, inklusive @aljones, @cutig3r, @huan086, @kmataru, @mehdihaghshenas och @vyrotek

Modelleringshierarkier

Den HierarchyId typen kan användas för egenskaper av en entitetstyp. Tänk dig till exempel att vi vill modellera upp släktträdet för några fiktiva halvlingar. I entitetstypen för Halflingkan en HierarchyId-egenskap användas för att hitta varje halvering i släktträdet.

public class Halfling
{
    public Halfling(HierarchyId pathFromPatriarch, string name, int? yearOfBirth = null)
    {
        PathFromPatriarch = pathFromPatriarch;
        Name = name;
        YearOfBirth = yearOfBirth;
    }

    public int Id { get; private set; }
    public HierarchyId PathFromPatriarch { get; set; }
    public string Name { get; set; }
    public int? YearOfBirth { get; set; }
}

Tip

Koden som visas här och i exemplen nedan kommer från HierarchyIdSample.cs.

Tip

Om du vill kan HierarchyId användas som nyckelegenskapstyp.

I det här fallet är släktträdet rotat med familjens patriark. Varje halvlängd kan spåras från Patriarken ner i trädet med hjälp av dess PathFromPatriarch egenskap. SQL Server använder ett kompakt binärt format för dessa sökvägar, men det är vanligt att parsa till och från en strängrepresentation som kan läsas av människor när du arbetar med kod. I den här representationen avgränsas positionen på varje nivå med ett / tecken. Tänk till exempel på släktträdet i diagrammet nedan:

Halfling familjeträd

I det här trädet:

  • Balbo ligger vid trädets rot, representerad av /.
  • Balbo har fem barn som representeras av /1/, /2/, /3/, /4/och /5/.
  • Balbos första barn, Mungo, har också fem barn, som representeras av /1/1/, /1/2/, /1/3/, /1/4/och /1/5/. Observera att HierarchyId för Balbo (/1/) är prefixet för alla sina barn.
  • På samma sätt har Balbos tredje barn, Ponto, två barn, representerade av /3/1/ och /3/2/. Återigen är vart och ett av dessa barn försett med prefixet HierarchyId för Ponto, vilket representeras som /3/.
  • Och så vidare ner i trädet...

Följande kod infogar det här släktträdet i en databas med EF Core:

await AddRangeAsync(
    new Halfling(HierarchyId.Parse("/"), "Balbo", 1167),
    new Halfling(HierarchyId.Parse("/1/"), "Mungo", 1207),
    new Halfling(HierarchyId.Parse("/2/"), "Pansy", 1212),
    new Halfling(HierarchyId.Parse("/3/"), "Ponto", 1216),
    new Halfling(HierarchyId.Parse("/4/"), "Largo", 1220),
    new Halfling(HierarchyId.Parse("/5/"), "Lily", 1222),
    new Halfling(HierarchyId.Parse("/1/1/"), "Bungo", 1246),
    new Halfling(HierarchyId.Parse("/1/2/"), "Belba", 1256),
    new Halfling(HierarchyId.Parse("/1/3/"), "Longo", 1260),
    new Halfling(HierarchyId.Parse("/1/4/"), "Linda", 1262),
    new Halfling(HierarchyId.Parse("/1/5/"), "Bingo", 1264),
    new Halfling(HierarchyId.Parse("/3/1/"), "Rosa", 1256),
    new Halfling(HierarchyId.Parse("/3/2/"), "Polo"),
    new Halfling(HierarchyId.Parse("/4/1/"), "Fosco", 1264),
    new Halfling(HierarchyId.Parse("/1/1/1/"), "Bilbo", 1290),
    new Halfling(HierarchyId.Parse("/1/3/1/"), "Otho", 1310),
    new Halfling(HierarchyId.Parse("/1/5/1/"), "Falco", 1303),
    new Halfling(HierarchyId.Parse("/3/2/1/"), "Posco", 1302),
    new Halfling(HierarchyId.Parse("/3/2/2/"), "Prisca", 1306),
    new Halfling(HierarchyId.Parse("/4/1/1/"), "Dora", 1302),
    new Halfling(HierarchyId.Parse("/4/1/2/"), "Drogo", 1308),
    new Halfling(HierarchyId.Parse("/4/1/3/"), "Dudo", 1311),
    new Halfling(HierarchyId.Parse("/1/3/1/1/"), "Lotho", 1310),
    new Halfling(HierarchyId.Parse("/1/5/1/1/"), "Poppy", 1344),
    new Halfling(HierarchyId.Parse("/3/2/1/1/"), "Ponto", 1346),
    new Halfling(HierarchyId.Parse("/3/2/1/2/"), "Porto", 1348),
    new Halfling(HierarchyId.Parse("/3/2/1/3/"), "Peony", 1350),
    new Halfling(HierarchyId.Parse("/4/1/2/1/"), "Frodo", 1368),
    new Halfling(HierarchyId.Parse("/4/1/3/1/"), "Daisy", 1350),
    new Halfling(HierarchyId.Parse("/3/2/1/1/1/"), "Angelica", 1381));

await SaveChangesAsync();

Tip

Om det behövs kan decimalvärden användas för att skapa nya noder mellan två befintliga noder. Till exempel går /3/2.5/2/ mellan /3/2/2/ och /3/3/2/.

Köra frågor mot hierarkier

HierarchyId exponerar flera metoder som kan användas i LINQ-frågor.

Method Description
GetAncestor(int n) Hämtar noden n jämnar upp det hierarkiska trädet.
GetDescendant(HierarchyId? child1, HierarchyId? child2) Hämtar värdet för en underordnade nod som är större än child1 och mindre än child2.
GetLevel() Hämtar nivån för den här noden i det hierarkiska trädet.
GetReparentedValue(HierarchyId? oldRoot, HierarchyId? newRoot) Hämtar ett värde som representerar platsen för en ny nod som har en sökväg från newRoot lika med sökvägen från oldRoot till detta, vilket effektivt flyttar den till den nya platsen.
IsDescendantOf(HierarchyId? parent) Hämtar ett värde som anger om den här noden är en efterföljande nod till parent.

Dessutom kan operatorerna ==, !=, <, <=, > och >= användas.

Följande är exempel på hur du använder dessa metoder i LINQ-frågor.

Hämta entiteter på en viss nivå i trädet

Följande fråga använder GetLevel för att returnera alla halvlingar på en viss nivå i släktträdet:

var generation = await context.Halflings.Where(halfling => halfling.PathFromPatriarch.GetLevel() == level).ToListAsync();

Detta översätts till följande SQL:

SELECT [h].[Id], [h].[Name], [h].[PathFromPatriarch], [h].[YearOfBirth]
FROM [Halflings] AS [h]
WHERE [h].[PathFromPatriarch].GetLevel() = @__level_0

Genom att köra detta i en loop kan vi få fram halvlingar för varje generation:

Generation 0: Balbo
Generation 1: Mungo, Pansy, Ponto, Largo, Lily
Generation 2: Bungo, Belba, Longo, Linda, Bingo, Rosa, Polo, Fosco
Generation 3: Bilbo, Otho, Falco, Posco, Prisca, Dora, Drogo, Dudo
Generation 4: Lotho, Poppy, Ponto, Porto, Peony, Frodo, Daisy
Generation 5: Angelica

Hämta den direkta överordnade för en entitet

Följande fråga använder GetAncestor för att hitta den direkta förfadern till en halvlängdsmänniska, givet halvlängdsmänniskans namn.

async Task<Halfling?> FindDirectAncestor(string name)
    => await context.Halflings
        .SingleOrDefaultAsync(
            ancestor => ancestor.PathFromPatriarch == context.Halflings
                .Single(descendent => descendent.Name == name).PathFromPatriarch
                .GetAncestor(1));

Detta översätts till följande SQL:

SELECT TOP(2) [h].[Id], [h].[Name], [h].[PathFromPatriarch], [h].[YearOfBirth]
FROM [Halflings] AS [h]
WHERE [h].[PathFromPatriarch] = (
    SELECT TOP(1) [h0].[PathFromPatriarch]
    FROM [Halflings] AS [h0]
    WHERE [h0].[Name] = @__name_0).GetAncestor(1)

Om du kör den här frågan för den halva "Bilbo" returneras "Bungo".

Hämta direkta underordnade objekt för en entitet

Följande fråga använder GetAncestor också, men den här gången för att hitta de direkta ättlingarna till en halvlängd, givet den halvlängds namn:

IQueryable<Halfling> FindDirectDescendents(string name)
    => context.Halflings.Where(
        descendent => descendent.PathFromPatriarch.GetAncestor(1) == context.Halflings
            .Single(ancestor => ancestor.Name == name).PathFromPatriarch);

Detta översätts till följande SQL:

SELECT [h].[Id], [h].[Name], [h].[PathFromPatriarch], [h].[YearOfBirth]
FROM [Halflings] AS [h]
WHERE [h].[PathFromPatriarch].GetAncestor(1) = (
    SELECT TOP(1) [h0].[PathFromPatriarch]
    FROM [Halflings] AS [h0]
    WHERE [h0].[Name] = @__name_0)

Om du kör den här frågan för den halva "Mungo" returneras "Bungo", "Belba", "Longo" och "Linda".

Hämta alla förfäder till en entitet

GetAncestor är användbart för att söka upp eller ned en enda nivå, eller faktiskt ett angivet antal nivåer. Å andra sidan är IsDescendantOf användbart för att hitta alla överordnade eller beroenden. Följande fråga använder till exempel IsDescendantOf för att hitta alla förfäder till en halvlängdsman, givet halvlängdsmanens namn.

IQueryable<Halfling> FindAllAncestors(string name)
    => context.Halflings.Where(
            ancestor => context.Halflings
                .Single(
                    descendent =>
                        descendent.Name == name
                        && ancestor.Id != descendent.Id)
                .PathFromPatriarch.IsDescendantOf(ancestor.PathFromPatriarch))
        .OrderByDescending(ancestor => ancestor.PathFromPatriarch.GetLevel());

Important

IsDescendantOf returnerar sant för sig själv, vilket är varför det filtreras bort i sökfrågan ovan.

Detta översätts till följande SQL:

SELECT [h].[Id], [h].[Name], [h].[PathFromPatriarch], [h].[YearOfBirth]
FROM [Halflings] AS [h]
WHERE (
    SELECT TOP(1) [h0].[PathFromPatriarch]
    FROM [Halflings] AS [h0]
    WHERE [h0].[Name] = @__name_0 AND [h].[Id] <> [h0].[Id]).IsDescendantOf([h].[PathFromPatriarch]) = CAST(1 AS bit)
ORDER BY [h].[PathFromPatriarch].GetLevel() DESC

Om du kör den här frågan för den halva "Bilbo" returneras "Bungo", "Mungo" och "Balbo".

Hämta alla härledda enheter till en entitet

Följande fråga använder också IsDescendantOf, men den här gången till alla ättlingar till en halvlängdsman, givet halvlängdsmannens namn:

IQueryable<Halfling> FindAllDescendents(string name)
    => context.Halflings.Where(
            descendent => descendent.PathFromPatriarch.IsDescendantOf(
                context.Halflings
                    .Single(
                        ancestor =>
                            ancestor.Name == name
                            && descendent.Id != ancestor.Id)
                    .PathFromPatriarch))
        .OrderBy(descendent => descendent.PathFromPatriarch.GetLevel());

Detta översätts till följande SQL:

SELECT [h].[Id], [h].[Name], [h].[PathFromPatriarch], [h].[YearOfBirth]
FROM [Halflings] AS [h]
WHERE [h].[PathFromPatriarch].IsDescendantOf((
    SELECT TOP(1) [h0].[PathFromPatriarch]
    FROM [Halflings] AS [h0]
    WHERE [h0].[Name] = @__name_0 AND [h].[Id] <> [h0].[Id])) = CAST(1 AS bit)
ORDER BY [h].[PathFromPatriarch].GetLevel()

Om du kör den här frågan för hälftlingen "Mungo" returneras "Bungo", "Belba", "Longo", "Linda", "Bingo", "Bilbo", "Otho", "Falco", "Lotho" och "Poppy".

Hitta en gemensam förfader

En av de vanligaste frågorna som ställs om just det här släktträdet är"vem är den gemensamma förfadern till Frodo och Bilbo?" Vi kan använda IsDescendantOf för att skriva en sådan fråga:

async Task<Halfling?> FindCommonAncestor(Halfling first, Halfling second)
    => await context.Halflings
        .Where(
            ancestor => first.PathFromPatriarch.IsDescendantOf(ancestor.PathFromPatriarch)
                        && second.PathFromPatriarch.IsDescendantOf(ancestor.PathFromPatriarch))
        .OrderByDescending(ancestor => ancestor.PathFromPatriarch.GetLevel())
        .FirstOrDefaultAsync();

Detta översätts till följande SQL:

SELECT TOP(1) [h].[Id], [h].[Name], [h].[PathFromPatriarch], [h].[YearOfBirth]
FROM [Halflings] AS [h]
WHERE @__first_PathFromPatriarch_0.IsDescendantOf([h].[PathFromPatriarch]) = CAST(1 AS bit)
  AND @__second_PathFromPatriarch_1.IsDescendantOf([h].[PathFromPatriarch]) = CAST(1 AS bit)
ORDER BY [h].[PathFromPatriarch].GetLevel() DESC

Att köra den här frågan med "Bilbo" och "Frodo" säger oss att deras gemensamma förfader är "Balbo".

Uppdatera hierarkier

De normala ändringsspårningsmekanismerna och SaveChanges kan användas för att uppdatera hierarchyid kolumner.

Överordna en underhierarki

Till exempel är jag säker på att vi alla minns skandalen med SR 1752 (även kallat "LongoGate") när DNA-tester avslöjade att Longo faktiskt inte var son till Mungo, utan faktiskt son till Ponto! Ett resultat av denna skandal var att släktträdet behövde skrivas om. I synnerhet behövde Longo och alla hans ättlingar bli omföräldrade från Mungo till Ponto. GetReparentedValue kan användas för att göra detta. Till exempel efterfrågas först "Longo" och alla hans ättlingar:

var longoAndDescendents = await context.Halflings.Where(
        descendent => descendent.PathFromPatriarch.IsDescendantOf(
            context.Halflings.Single(ancestor => ancestor.Name == "Longo").PathFromPatriarch))
    .ToListAsync();

Sedan används GetReparentedValue för att uppdatera HierarchyId för Longo och varje underordnad, följt av ett anrop till SaveChangesAsync:

foreach (var descendent in longoAndDescendents)
{
    descendent.PathFromPatriarch
        = descendent.PathFromPatriarch.GetReparentedValue(
            mungo.PathFromPatriarch, ponto.PathFromPatriarch)!;
}

await context.SaveChangesAsync();

Detta resulterar i följande databasuppdatering:

SET NOCOUNT ON;
UPDATE [Halflings] SET [PathFromPatriarch] = @p0
OUTPUT 1
WHERE [Id] = @p1;
UPDATE [Halflings] SET [PathFromPatriarch] = @p2
OUTPUT 1
WHERE [Id] = @p3;
UPDATE [Halflings] SET [PathFromPatriarch] = @p4
OUTPUT 1
WHERE [Id] = @p5;

Använd följande parametrar:

 @p1='9',
 @p0='0x7BC0' (Nullable = false) (Size = 2) (DbType = Object),
 @p3='16',
 @p2='0x7BD6' (Nullable = false) (Size = 2) (DbType = Object),
 @p5='23',
 @p4='0x7BD6B0' (Nullable = false) (Size = 3) (DbType = Object)

Note

Parametrarnas värden för HierarchyId egenskaper skickas till databasen i deras kompakta binära format.

Efter uppdateringen returnerar frågan efter ättlingarna till "Mungo" "Bungo", "Belba", "Linda", "Bingo", "Bilbo", "Falco" och "Poppy", medan frågan efter ättlingarna till "Ponto" returnerar "Longo", "Rosa", "Polo", "Otho", "Posco", "Prisca", "Lotho", "Ponto", "Porto", "Peony" och "Angelica".

Raw SQL-frågor för ommappade typer

EF7 introducerade råa SQL-frågor som returnerar skalära typer. Detta har förbättrats i EF8 för att inkludera råa SQL-frågor som returnerar någon mappbar CLR-typ, utan att inkludera den typen i EF-modellen.

Tip

Koden som visas här kommer från RawSqlSample.cs.

Frågor med omappade typer körs med SqlQuery eller SqlQueryRaw. Den förstnämnda använder stränginterpolation för att parametrisera frågan, vilket säkerställer att alla icke-konstanta värden parametriseras. Tänk till exempel på följande databastabell:

CREATE TABLE [Posts] (
    [Id] int NOT NULL IDENTITY,
    [Title] nvarchar(max) NOT NULL,
    [Content] nvarchar(max) NOT NULL,
    [PublishedOn] date NOT NULL,
    [BlogId] int NOT NULL,
);

SqlQuery kan användas för att fråga den här tabellen och returnera instanser av en BlogPost typ med egenskaper som motsvarar kolumnerna i tabellen:

Till exempel:

public class BlogPost
{
    public int Id { get; set; }
    public string Title { get; set; }
    public string Content { get; set; }
    public DateOnly PublishedOn { get; set; }
    public int BlogId { get; set; }
}

Till exempel:

var start = new DateOnly(2022, 1, 1);
var end = new DateOnly(2023, 1, 1);
var postsIn2022 =
    await context.Database
        .SqlQuery<BlogPost>($"SELECT * FROM Posts as p WHERE p.PublishedOn >= {start} AND p.PublishedOn < {end}")
        .ToListAsync();

Den här frågan parametriseras och körs som:

SELECT * FROM Posts as p WHERE p.PublishedOn >= @p0 AND p.PublishedOn < @p1

Den typ som används för frågeresultat kan innehålla vanliga mappningskonstruktioner som stöds av EF Core, till exempel parametriserade konstruktorer och mappningsattribut. Till exempel:

public class BlogPost
{
    public BlogPost(string blogTitle, string content, DateOnly publishedOn)
    {
        BlogTitle = blogTitle;
        Content = content;
        PublishedOn = publishedOn;
    }

    public int Id { get; private set; }

    [Column("Title")]
    public string BlogTitle { get; set; }

    public string Content { get; set; }
    public DateOnly PublishedOn { get; set; }
    public int BlogId { get; set; }
}

Note

Typer som används på det här sättet har inte nycklar definierade och kan inte ha relationer till andra typer. Typer med relationer måste mappas i modellen.

Den typ som används måste ha en egenskap för varje värde i resultatuppsättningen, men behöver inte matcha någon tabell i databasen. Följande typ representerar till exempel bara en delmängd av informationen för varje inlägg och innehåller bloggnamnet, som kommer från Blogs tabellen:

public class PostSummary
{
    public string BlogName { get; set; } = null!;
    public string PostTitle { get; set; } = null!;
    public DateOnly? PublishedOn { get; set; }
}

Och kan frågas med samma SqlQuery sätt som tidigare:


var cutoffDate = new DateOnly(2022, 1, 1);
var summaries =
    await context.Database.SqlQuery<PostSummary>(
            @$"SELECT b.Name AS BlogName, p.Title AS PostTitle, p.PublishedOn
               FROM Posts AS p
               INNER JOIN Blogs AS b ON p.BlogId = b.Id
               WHERE p.PublishedOn >= {cutoffDate}")
        .ToListAsync();

En bra funktion med SqlQuery är att den returnerar en IQueryable som kan komponeras med hjälp av LINQ. Till exempel kan en "Where"-sats läggas till i frågan ovan:

var summariesIn2022 =
    await context.Database.SqlQuery<PostSummary>(
            @$"SELECT b.Name AS BlogName, p.Title AS PostTitle, p.PublishedOn
               FROM Posts AS p
               INNER JOIN Blogs AS b ON p.BlogId = b.Id")
        .Where(p => p.PublishedOn >= cutoffDate && p.PublishedOn < end)
        .ToListAsync();

Detta utförs som:

SELECT [n].[BlogName], [n].[PostTitle], [n].[PublishedOn]
FROM (
         SELECT b.Name AS BlogName, p.Title AS PostTitle, p.PublishedOn
         FROM Posts AS p
                  INNER JOIN Blogs AS b ON p.BlogId = b.Id
     ) AS [n]
WHERE [n].[PublishedOn] >= @__cutoffDate_1 AND [n].[PublishedOn] < @__end_2

I det här läget är det värt att komma ihåg att alla ovanstående kan göras helt i LINQ utan att behöva skriva någon SQL. Detta inkluderar att returnera instanser av en omappad typ som PostSummary. Till exempel kan föregående fråga skrivas i LINQ som:

var summaries =
    await context.Posts.Select(
            p => new PostSummary
            {
                BlogName = p.Blog.Name,
                PostTitle = p.Title,
                PublishedOn = p.PublishedOn,
            })
        .Where(p => p.PublishedOn >= start && p.PublishedOn < end)
        .ToListAsync();

Vilket innebär mycket renare SQL:

SELECT [b].[Name] AS [BlogName], [p].[Title] AS [PostTitle], [p].[PublishedOn]
FROM [Posts] AS [p]
INNER JOIN [Blogs] AS [b] ON [p].[BlogId] = [b].[Id]
WHERE [p].[PublishedOn] >= @__start_0 AND [p].[PublishedOn] < @__end_1

Tip

EF kan generera renare SQL när det är ansvarigt för hela frågan än när den skapas över användarhanterad SQL eftersom den fullständiga semantiken för frågan i det tidigare fallet är tillgänglig för EF.

Hittills har alla frågor körts direkt mot tabeller. SqlQuery kan också användas för att returnera resultat från en vy utan att mappa vytypen i EF-modellen. Till exempel:

var summariesFromView =
    await context.Database.SqlQuery<PostSummary>(
            @$"SELECT * FROM PostAndBlogSummariesView")
        .Where(p => p.PublishedOn >= cutoffDate && p.PublishedOn < end)
        .ToListAsync();

SqlQuery På samma sätt kan användas för resultatet av en funktion:

var summariesFromFunc =
    await context.Database.SqlQuery<PostSummary>(
            @$"SELECT * FROM GetPostsPublishedAfter({cutoffDate})")
        .Where(p => p.PublishedOn < end)
        .ToListAsync();

Den returnerade IQueryable kan bearbetas som ett resultat av en vy eller funktion, precis som det kan vara för resultatet av en tabellfråga. Lagrade procedurer kan också köras med hjälp av SqlQuery, men de flesta databaser stöder inte sammansättning över dem. Till exempel:

var summariesFromStoredProc =
    await context.Database.SqlQuery<PostSummary>(
            @$"exec GetRecentPostSummariesProc")
        .ToListAsync();

Förbättringar av lazy-loading

Lazy-loading för frågor utan spårning

EF8 lägger till stöd för fördröjd inläsning av navigeringar på entiteter som inte spåras av DbContext. Det innebär att en spårningsfri fråga kan följas av fördröjd inläsning av navigering på de entiteter som returneras av den spårningsfria frågan.

Tip

Koden för exemplen på lazy loading som visas i det följande kommer från LazyLoadingSample.cs.

Du kan till exempel överväga en fråga utan spårning för bloggar:

var blogs = await context.Blogs.AsNoTracking().ToListAsync();

Om Blog.Posts har konfigurerats för lat inläsning, till exempel med hjälp av proxyservrar med lat inläsning, kommer åtkomsten Posts att leda till att den läses in från databasen:

Console.WriteLine();
Console.Write("Choose a blog: ");
if (int.TryParse(ReadLine(), out var blogId))
{
    Console.WriteLine("Posts:");
    foreach (var post in blogs[blogId - 1].Posts)
    {
        Console.WriteLine($"  {post.Title}");
    }
}

EF8 rapporterar även om en viss navigering läses in för entiteter som inte spåras av kontexten. Till exempel:

foreach (var blog in blogs)
{
    if (context.Entry(blog).Collection(e => e.Posts).IsLoaded)
    {
        Console.WriteLine($" Posts for blog '{blog.Name}' are loaded.");
    }
}

Det finns några viktiga saker att tänka på när du använder lazy-loading på det här sättet:

  • Lazy-loading kommer bara fungera tills den DbContext som används för att fråga entiteten frigörs.
  • Entiteter som efterfrågas på det här sättet upprätthåller en referens till deras DbContext, även om de inte spåras av den. Var noga med att undvika minnesläckor om entitetsinstanserna har lång livslängd.
  • Om du uttryckligen kopplar från entiteten genom att ange dess tillstånd till EntityState.Detached, så bryts referensen till DbContext och lazy-loading kommer inte längre att fungera.
  • Kom ihåg att all lazy-loading använder synkron I/O eftersom det inte finns något sätt att komma åt en egenskap på ett asynkront sätt.

Lazy-loading från ospårade entiteter fungerar för både lazy-loading proxyservrar och lazy-loading utan proxyservrar.

Explicit laddning från spårlösa entiteter

EF8 stöder inläsning av navigeringsegenskaper hos ospårade entiteter även om entiteten eller egenskaperna inte har konfigurerats för lat inläsning. Till skillnad från vid lat inläsning kan den här explicita inläsningen utföras asynkront. Till exempel:

await context.Entry(blog).Collection(e => e.Posts).LoadAsync();

Avaktivering av lazy-loading för specifika navigeringer

EF8 gör det möjligt att konfigurera specifika navigeringar att inte använda fördröjd laddning, även när allt annat är inställt för att göra det. Om du till exempel vill konfigurera navigeringen så att den Post.Author inte är lazy-load gör du följande:

modelBuilder
    .Entity<Post>()
    .Navigation(p => p.Author)
    .EnableLazyLoading(false);

Att inaktivera Lazy-loading så här fungerar för både lazy-loading proxyservrar och lazy-loading utan proxyservrar.

Proxyservrar med lat inläsning fungerar genom att åsidosätta egenskaper för virtuell navigering. I klassiska EF6-applikationer är en vanlig källa till buggar att glömma att göra en virtuell navigering, eftersom navigeringen då inte laddas upp tyst och smidigt. Därför utlöser EF Core-proxyservrar som standard när en navigering inte är virtuell.

Detta kan ändras i EF8 för att välja det klassiska EF6-beteendet så att en navigering kan göras för att inte lat-läsa in helt enkelt genom att göra navigeringen icke-virtuell. Den här anmälningen konfigureras som en del av anropet till UseLazyLoadingProxies. Till exempel:

optionsBuilder.UseLazyLoadingProxies(b => b.IgnoreNonVirtualNavigations());

Åtkomst till spårade entiteter

Slå upp spårade entiteter med primär, alternativ eller extern nyckel

Internt underhåller EF datastrukturer för att hitta spårade entiteter efter primär, alternativ eller sekundär nyckel. Dessa datastrukturer används för effektiv korrigering mellan relaterade entiteter när nya entiteter spåras eller relationer ändras.

EF8 innehåller nya offentliga API:er så att program nu kan använda dessa datastrukturer för att effektivt söka upp spårade entiteter. Dessa API:er nås via entitetstypen LocalView<TEntity> . Om du till exempel vill söka efter en spårad entitet med dess primära nyckel:

var blogEntry = context.Blogs.Local.FindEntry(2)!;

Tip

Koden som visas här kommer från LookupByKeySample.cs.

Metoden FindEntry returnerar antingen EntityEntry<TEntity> för den spårade entiteten eller null om ingen entitet med den angivna nyckeln spåras. Precis som alla metoder på LocalViewefterfrågas aldrig databasen, även om entiteten inte hittas. Den returnerade posten innehåller själva entiteten samt spårningsinformation. Till exempel:

Console.WriteLine($"Blog '{blogEntry.Entity.Name}' with key {blogEntry.Entity.Id} is tracked in the '{blogEntry.State}' state.");

Om du letar upp en entitet med något annat än en primärnyckel måste egenskapsnamnet anges. Om du till exempel vill söka efter en alternativ nyckel:

var siteEntry = context.Websites.Local.FindEntry(nameof(Website.Uri), new Uri("https://www.bricelam.net/"))!;

Eller för att slå upp med en unik främmande nyckel:

var blogAtSiteEntry = context.Blogs.Local.FindEntry(nameof(Blog.SiteUri), new Uri("https://www.bricelam.net/"))!;

Hittills har sökningarna alltid returnerat en enda post, eller null. Vissa sökningar kan dock returnera mer än en post, till exempel när du söker efter en icke-unik utländsk nyckel. Metoden GetEntries ska användas för dessa sökningar. Till exempel:

var postEntries = context.Posts.Local.GetEntries(nameof(Post.BlogId), 2);

I alla dessa fall är det värde som används för sökningen antingen en primärnyckel, en alternativ nyckel eller ett sekundärnyckelvärde. EF använder sina interna datastrukturer för dessa sökningar. Sökningar efter värde kan dock också användas för värdet för valfri egenskap eller kombination av egenskaper. Om du till exempel vill hitta alla arkiverade inlägg:

var archivedPostEntries = context.Posts.Local.GetEntries(nameof(Post.Archived), true);

Den här sökningen kräver en genomsökning av alla spårade Post instanser och blir därför mindre effektiv än nyckelsökningar. Det är dock vanligtvis fortfarande snabbare än naiva frågor med hjälp av ChangeTracker.Entries<TEntity>().

Slutligen är det också möjligt att utföra sökningar mot sammansatta nycklar, andra kombinationer av flera egenskaper eller när egenskapstypen inte är känd vid kompileringstiden. Till exempel:

var postTagEntry = context.Set<PostTag>().Local.FindEntryUntyped(new object[] { 4, "TagEF" });

Modellbyggnad

Diskriminerande kolumner har maximal längd

I EF8 konfigureras nu strängdiskriminerande kolumner som används för TPH-arvsmappning med maximal längd. Den här längden beräknas som det minsta Fibonacci-talet som täcker alla definierade diskriminerande värden. Tänk till exempel på följande hierarki:

public abstract class Document
{
    public int Id { get; set; }
    public string Title { get; set; }
}

public abstract class Book : Document
{
    public string? Isbn { get; set; }
}

public class PaperbackEdition : Book
{
}

public class HardbackEdition : Book
{
}

public class Magazine : Document
{
    public int IssueNumber { get; set; }
}

Med konventionen om att använda klassnamnen för diskriminerande värden är de möjliga värdena här "PaperbackEdition", "HardbackEdition" och "Magazine", och därför konfigureras den diskriminerande kolumnen för en maximal längd på 21. När du till exempel använder SQL Server:

CREATE TABLE [Documents] (
    [Id] int NOT NULL IDENTITY,
    [Title] nvarchar(max) NOT NULL,
    [Discriminator] nvarchar(21) NOT NULL,
    [Isbn] nvarchar(max) NULL,
    [IssueNumber] int NULL,
    CONSTRAINT [PK_Documents] PRIMARY KEY ([Id]),

Tip

Fibonacci-tal används för att begränsa antalet gånger en migrering genereras för att ändra kolumnlängden när nya typer läggs till i hierarkin.

DateOnly/TimeOnly stöds på SQL Server

Typerna DateOnly och TimeOnly introducerades i .NET 6 och har sedan dess fått stöd för flera databasprovidrar (t.ex. SQLite, MySQL och PostgreSQL). För SQL Server har den senaste versionen av ett Microsoft.Data.SqlClient-paket som riktar sig till .NET 6 gjort det möjligt för ErikEJ att lägga till stöd för dessa typer på ADO.NET nivå. Detta banade i sin tur vägen för stöd i EF8 för DateOnly och TimeOnly som egenskaper i entitetstyper.

Tip

DateOnly och TimeOnly kan användas i EF Core 6 och 7 med hjälp av Community-paketet ErikEJ.EntityFrameworkCore.SqlServer.DateOnlyTimeOnly från @ErikEJ.

Tänk till exempel på följande EF-modell för brittiska skolor:

public class School
{
    public int Id { get; set; }
    public string Name { get; set; } = null!;
    public DateOnly Founded { get; set; }
    public List<Term> Terms { get; } = new();
    public List<OpeningHours> OpeningHours { get; } = new();
}

public class Term
{
    public int Id { get; set; }
    public string Name { get; set; } = null!;
    public DateOnly FirstDay { get; set; }
    public DateOnly LastDay { get; set; }
    public School School { get; set; } = null!;
}

[Owned]
public class OpeningHours
{
    public OpeningHours(DayOfWeek dayOfWeek, TimeOnly? opensAt, TimeOnly? closesAt)
    {
        DayOfWeek = dayOfWeek;
        OpensAt = opensAt;
        ClosesAt = closesAt;
    }

    public DayOfWeek DayOfWeek { get; private set; }
    public TimeOnly? OpensAt { get; set; }
    public TimeOnly? ClosesAt { get; set; }
}

Tip

Koden som visas här kommer från DateOnlyTimeOnlySample.cs.

Note

Den här modellen representerar endast brittiska skolor och lagrar tider som lokala (GMT) tider. Att hantera olika tidszoner skulle komplicera koden avsevärt. Observera att användning DateTimeOffset inte skulle hjälpa här, eftersom öppnings- och stängningstider har olika förskjutningar beroende på om sommartid är aktiv eller inte.

Dessa entitetstyper mappas till följande tabeller när du använder SQL Server. Observera att egenskaperna DateOnly mappas till date kolumner, och att egenskaperna TimeOnly mappas till time kolumner.

CREATE TABLE [Schools] (
    [Id] int NOT NULL IDENTITY,
    [Name] nvarchar(max) NOT NULL,
    [Founded] date NOT NULL,
    CONSTRAINT [PK_Schools] PRIMARY KEY ([Id]));

CREATE TABLE [OpeningHours] (
    [SchoolId] int NOT NULL,
    [Id] int NOT NULL IDENTITY,
    [DayOfWeek] int NOT NULL,
    [OpensAt] time NULL,
    [ClosesAt] time NULL,
    CONSTRAINT [PK_OpeningHours] PRIMARY KEY ([SchoolId], [Id]),
    CONSTRAINT [FK_OpeningHours_Schools_SchoolId] FOREIGN KEY ([SchoolId]) REFERENCES [Schools] ([Id]) ON DELETE CASCADE);

CREATE TABLE [Term] (
    [Id] int NOT NULL IDENTITY,
    [Name] nvarchar(max) NOT NULL,
    [FirstDay] date NOT NULL,
    [LastDay] date NOT NULL,
    [SchoolId] int NOT NULL,
    CONSTRAINT [PK_Term] PRIMARY KEY ([Id]),
    CONSTRAINT [FK_Term_Schools_SchoolId] FOREIGN KEY ([SchoolId]) REFERENCES [Schools] ([Id]) ON DELETE CASCADE);

Frågor som använder DateOnly och TimeOnly fungerar på det förväntade sättet. Följande LINQ-fråga hittar till exempel skolor som för närvarande är öppna:

openSchools = await context.Schools
    .Where(
        s => s.Terms.Any(
                 t => t.FirstDay <= today
                      && t.LastDay >= today)
             && s.OpeningHours.Any(
                 o => o.DayOfWeek == dayOfWeek
                      && o.OpensAt < time && o.ClosesAt >= time))
    .ToListAsync();

Den här frågan översätts till följande SQL, som visas av ToQueryString:

DECLARE @__today_0 date = '2023-02-07';
DECLARE @__dayOfWeek_1 int = 2;
DECLARE @__time_2 time = '19:53:40.4798052';

SELECT [s].[Id], [s].[Founded], [s].[Name], [o0].[SchoolId], [o0].[Id], [o0].[ClosesAt], [o0].[DayOfWeek], [o0].[OpensAt]
FROM [Schools] AS [s]
LEFT JOIN [OpeningHours] AS [o0] ON [s].[Id] = [o0].[SchoolId]
WHERE EXISTS (
    SELECT 1
    FROM [Term] AS [t]
    WHERE [s].[Id] = [t].[SchoolId] AND [t].[FirstDay] <= @__today_0 AND [t].[LastDay] >= @__today_0) AND EXISTS (
    SELECT 1
    FROM [OpeningHours] AS [o]
    WHERE [s].[Id] = [o].[SchoolId] AND [o].[DayOfWeek] = @__dayOfWeek_1 AND [o].[OpensAt] < @__time_2 AND [o].[ClosesAt] >= @__time_2)
ORDER BY [s].[Id], [o0].[SchoolId]

DateOnly och TimeOnly kan också användas i JSON-kolumner. Kan till exempel OpeningHours sparas som ett JSON-dokument, vilket resulterar i data som ser ut så här:

Column Value
Id 2
Name Farr högstadium
Founded 1964-05-01
OpeningHours
[
{ "DayOfWeek": "Söndag", "ClosesAt": null, "OpensAt": null }
{ "DayOfWeek": "Monday", "ClosesAt": "15:35:00", "OpensAt": "08:45:00" },
{ "DayOfWeek": "Tuesday", "ClosesAt": "15:35:00", "OpensAt": "08:45:00" },
{ "DayOfWeek": "Wednesday", "ClosesAt": "15:35:00", "OpensAt": "08:45:00" },
{ "DayOfWeek": "Thursday", "ClosesAt": "15:35:00", "OpensAt": "08:45:00" },
{ "DayOfWeek": "Friday", "ClosesAt": "12:50:00", "OpensAt": "08:45:00" },
{ "Veckodag": "Saturday", "ClosesAt": null, "OpensAt": null }
]

Genom att kombinera två funktioner från EF8 kan vi nu fråga efter öppettider genom att indexera till JSON-samlingen. Till exempel:

openSchools = await context.Schools
    .Where(
        s => s.Terms.Any(
                 t => t.FirstDay <= today
                      && t.LastDay >= today)
             && s.OpeningHours[(int)dayOfWeek].OpensAt < time
             && s.OpeningHours[(int)dayOfWeek].ClosesAt >= time)
    .ToListAsync();

Den här frågan översätts till följande SQL, som visas av ToQueryString:

DECLARE @__today_0 date = '2023-02-07';
DECLARE @__dayOfWeek_1 int = 2;
DECLARE @__time_2 time = '20:14:34.7795877';

SELECT [s].[Id], [s].[Founded], [s].[Name], [s].[OpeningHours]
FROM [Schools] AS [s]
WHERE EXISTS (
    SELECT 1
    FROM [Term] AS [t]
    WHERE [s].[Id] = [t].[SchoolId] AND [t].[FirstDay] <= @__today_0
      AND [t].[LastDay] >= @__today_0)
      AND CAST(JSON_VALUE([s].[OpeningHours],'$[' + CAST(CAST(@__dayOfWeek_1 AS int) AS nvarchar(max)) + '].OpensAt') AS time) < @__time_2
      AND CAST(JSON_VALUE([s].[OpeningHours],'$[' + CAST(CAST(@__dayOfWeek_1 AS int) AS nvarchar(max)) + '].ClosesAt') AS time) >= @__time_2

Slutligen kan uppdateringar och borttagningar utföras med spårning och SaveChanges, eller med ExecuteUpdate/ExecuteDelete. Till exempel:

await context.Schools
    .Where(e => e.Terms.Any(t => t.LastDay.Year == 2022))
    .SelectMany(e => e.Terms)
    .ExecuteUpdateAsync(s => s.SetProperty(t => t.LastDay, t => t.LastDay.AddDays(1)));

Den här uppdateringen översätts till följande SQL:

UPDATE [t0]
SET [t0].[LastDay] = DATEADD(day, CAST(1 AS int), [t0].[LastDay])
FROM [Schools] AS [s]
INNER JOIN [Term] AS [t0] ON [s].[Id] = [t0].[SchoolId]
WHERE EXISTS (
    SELECT 1
    FROM [Term] AS [t]
    WHERE [s].[Id] = [t].[SchoolId] AND DATEPART(year, [t].[LastDay]) = 2022)

Omvänd ingenjör Synapse och Dynamics 365 TDS

EF8-bakåtkompilering (till exempel byggnadsställningar från en befintlig databas) stöder nu Synapse Serverless SQL Pool och Dynamics 365 TDS-slutpunktsdatabaser .

Warning

Dessa databassystem har skillnader från vanliga SQL Server- och Azure SQL-databaser. Dessa skillnader innebär att inte alla EF Core-funktioner stöds när du skriver frågor mot eller utför andra åtgärder med dessa databassystem.

Förbättringar av matematiska översättningar

Allmänna matematiska gränssnitt introducerades i .NET 7. Konkreta typer som double och float implementerade dessa gränssnitt och lade till nya API:er som speglar de befintliga funktionerna i Math och MathF.

EF Core 8 översätter anrop till dessa generiska matematiska API:er i LINQ med hjälp av leverantörernas befintliga SQL-översättningar för Math och MathF. Det innebär att du nu kan välja mellan anrop som Math.Sin eller double.Sin i dina EF-frågor.

Vi arbetade med .NET-teamet för att lägga till två nya generiska matematiska metoder i .NET 8 som implementeras på double och float. Dessa översätts också till SQL i EF Core 8.

.NET SQL
DegreesToRadians RADIANS
RadiansToDegrees DEGREES

Slutligen arbetade vi med Eric Sink i SQLitePCLRaw-projektet för att aktivera matematiska SQLite-funktioner i deras versioner av det inbyggda SQLite-biblioteket. Detta inkluderar det interna bibliotek som du får som standard när du installerar EF Core SQLite-providern. Detta möjliggör flera nya SQL-översättningar i LINQ, inklusive: Acos, Acosh, Asin, Asinh, Atan, Atan2, Atanh, Ceiling, Cos, Cosh, DegreesToRadians, Exp, Floor, Log, Log2, Log10, Pow, RadiansToDegrees, Sign, Sin, Sinh, Sqrt, Tan, Tanh och Truncate.

Söka efter väntande modelländringar

Vi har lagt till ett nytt dotnet ef kommando för att kontrollera om några modelländringar har gjorts sedan den senaste migreringen. Detta kan vara användbart i CI/CD-scenarier för att säkerställa att du eller en lagkamrat inte glömmer att lägga till en migrering.

dotnet ef migrations has-pending-model-changes

Du kan också utföra den här kontrollen programmatiskt i ditt program eller tester med den nya dbContext.Database.HasPendingModelChanges() metoden.

Förbättringar av SQLite-byggnadsställningar

SQLite stöder endast fyra primitiva datatyper – INTEGER, REAL, TEXT och BLOB. Tidigare innebar detta att när du bakåtkompilerade en SQLite-databas för att skapa en EF Core-modell, skulle de resulterande entitetstyperna endast innehålla egenskaper av typen long, double, stringoch byte[]. Ytterligare .NET-typer stöds av EF Core SQLite-providern genom att konvertera mellan dem och en av de fyra primitiva SQLite-typerna.

I EF Core 8 använder vi nu dataformatet och kolumntypsnamnet utöver SQLite-typen för att fastställa en lämpligare .NET-typ som ska användas i modellen. Följande tabeller visar några av de fall där ytterligare information leder till bättre egenskapstyper i modellen.

Kolumntypnamn .NET-typ
BOOLEAN byte[]bool
SMALLINT longshort
INT longint
BIGINT long
STRING byte[]string
Dataformat .NET-typ
'0.0' stringdecimal
'1970-01-01' stringDateOnly
'1970-01-01 00:00:00' stringDateTime
'00:00:00' stringTimeSpan
'00000000-0000-0000-0000-000000000000' stringGuid

Sentinel-värden och databasstandarder

Databaser tillåter att kolumner konfigureras för att generera ett standardvärde om inget värde anges när en rad infogas. Detta kan representeras i EF med hjälp av HasDefaultValue för konstanter:

b.Property(e => e.Status).HasDefaultValue("Hidden");

Eller HasDefaultValueSql för godtyckliga SQL-satser:

b.Property(e => e.LeaseDate).HasDefaultValueSql("getutcdate()");

Tip

Koden som visas nedan kommer från DefaultConstraintSample.cs.

För att EF ska kunna använda detta måste det avgöra när och när ett värde för kolumnen inte ska skickas. Som standardinställning använder EF CLR:s standardvärde som en markör för detta. Det vill säga när värdet för eller Status i exemplen ovan är CLR-standardvärdena för dessa typerLeaseDate och därför inte skickar något värde till databasen. Detta fungerar bra för referenstyper – till exempel, om egenskapen string är Status, skickas null inte till databasen av EF, utan inkluderar inget värde så att databasens standard (null) används. DateTime På samma sätt infogar EF inte CLR-standardvärdet LeaseDateför egenskapen 1/1/0001 12:00:00 AM, utan utelämnar i stället det här värdet så att databasens standardvärde används.

I vissa fall är dock CLR-standardvärdet ett giltigt värde att infoga. EF8 hanterar detta genom att tillåta att sentinel-värdet för en kolumn ändras. Anta till exempel att en heltalskolumn har konfigurerats med en databasstandard:

b.Property(e => e.Credits).HasDefaultValueSql(10);

I det här fallet vill vi att den nya entiteten ska infogas med det angivna antalet krediter, såvida inte detta inte anges, i vilket fall 10 krediter tilldelas. Det innebär dock att det inte går att infoga en post med noll krediter, eftersom noll är CLR-standardvärdet, vilket gör att EF inte skickar något värde. I EF8 kan detta åtgärdas genom att ändra sentinel för egenskapen från noll till -1:

b.Property(e => e.Credits).HasDefaultValueSql(10).HasSentinel(-1);

EF använder nu bara databasens standardvärde om Credits är inställt på -1. Ett värde på noll infogas som alla andra belopp.

Det kan ofta vara användbart att återspegla detta i entitetstypen samt i EF-konfigurationen. Till exempel:

public class Person
{
    public int Id { get; set; }
    public int Credits { get; set; } = -1;
}

Det innebär att sentinel-värdet för -1 anges automatiskt när instansen skapas, vilket innebär att egenskapen startar i sitt "inte-set"-tillstånd.

Tip

Om du vill konfigurera databasens standardvillkor för användning när Migrations du skapar kolumnen, men du vill att EF alltid ska infoga ett värde, konfigurerar du egenskapen som inte genererad. Till exempel b.Property(e => e.Credits).HasDefaultValueSql(10).ValueGeneratedNever();.

Standardinställningar för booleska värden i databasen

Booleska egenskaper utgör en extrem form av det här problemet, eftersom CLR-standardvärdet (false) är ett av endast två giltiga värden. Det innebär att en bool egenskap med en databasstandardvillkor endast har ett värde infogat om värdet är true. När databasens standardvärde är falseinnebär det att när egenskapsvärdet är falseanvänds databasens standardvärde, som är false. Annars, om egenskapsvärdet är true infogas true. Så när databasens standardvärde är falsehamnar databaskolumnen med rätt värde.

Om databasens standardvärde å andra sidan är trueinnebär det att när egenskapsvärdet är falseanvänds databasens standardvärde, vilket är true! Och när egenskapsvärdet är trueinfogas det true . Så värdet i kolumnen kommer alltid att sluta true i databasen, oavsett vad egenskapsvärdet är.

EF8 åtgärdar det här problemet genom att ställa in sentinel för bool-egenskaper till samma värde som databasens standardvärde. Båda fallen ovan resulterar sedan i att rätt värde infogas, oavsett om databasens standardvärde är true eller false.

Tip

När man skapar från en befintlig databas parsar EF8 och inkluderar sedan standardvärden i HasDefaultValue anrop. (Tidigare genererades alla standardvärden som obskyra HasDefaultValueSql anrop.) Det innebär att boolkolumner som har true eller false konstant databasstandard inte längre genereras som nullbara.

Förvalda värden för enum-datatyper i databasen

Uppräkningsegenskaper kan ha liknande problem som bool egenskaper eftersom uppräkningar vanligtvis har en mycket liten uppsättning giltiga värden, och CLR-standardvärdet kan vara ett av dessa värden. Tänk till exempel på den här entitetstypen och uppräkningen:

public class Course
{
    public int Id { get; set; }
    public Level Level { get; set; }
}

public enum Level
{
    Beginner,
    Intermediate,
    Advanced,
    Unspecified
}

Egenskapen Level konfigureras sedan med en databasstandard:

modelBuilder.Entity<Course>()
    .Property(e => e.Level)
    .HasDefaultValue(Level.Intermediate);

Med den här konfigurationen undantar EF att skicka värdet till databasen när det är inställt på Level.Beginner, och tilldelas i stället Level.Intermediate av databasen. Det här var inte avsett!

Problemet skulle inte ha inträffat om uppräkningen har definierats med värdet "okänd" eller "ospecificerad" som standard för databasen:

public enum Level
{
    Unspecified,
    Beginner,
    Intermediate,
    Advanced
}

Det är dock inte alltid möjligt att ändra en befintlig uppräkning, så i EF8 kan sentinel återigen anges. Om du till exempel går tillbaka till den ursprungliga enum:

modelBuilder.Entity<Course>()
    .Property(e => e.Level)
    .HasDefaultValue(Level.Intermediate)
    .HasSentinel(Level.Unspecified);

Nu Level.Beginner infogas som vanligt och databasens standardvärde används endast när egenskapsvärdet är Level.Unspecified. Det kan återigen vara användbart att återspegla detta i själva entitetstypen. Till exempel:

public class Course
{
    public int Id { get; set; }
    public Level Level { get; set; } = Level.Unspecified;
}

Använda ett null-stödfält

Ett mer allmänt sätt att hantera problemet som beskrivs ovan är att skapa ett null-stödfält för den icke-nullbara egenskapen. Tänk dig till exempel följande entitetstyp med en bool egenskap:

public class Account
{
    public int Id { get; set; }
    public bool IsActive { get; set; }
}

Egenskapen kan ges ett nullbart stödfält.

public class Account
{
    public int Id { get; set; }

    private bool? _isActive;

    public bool IsActive
    {
        get => _isActive ?? false;
        set => _isActive = value;
    }
}

Bakgrundsfältet här förblir nullom inte egenskapens setter faktiskt anropas. Värdet för bakgrundsfältet är alltså en bättre indikation på om egenskapen har angetts eller inte än CLR-standardvärdet för egenskapen. Detta fungerar direkt ur lådan med EF, eftersom att EF som standard använder bakgrundsfältet för att läsa och skriva egenskapen.

Bättre ExecuteUpdate och ExecuteDelete

SQL-kommandon som utför uppdateringar och borttagningar, till exempel de som genereras av ExecuteUpdate och ExecuteDelete metoder, måste rikta in sig på en enskild databastabell. Men i EF7 hade ExecuteUpdate och ExecuteDelete inte stöd för uppdateringar som har åtkomst till flera entitetstyper, även när frågan slutligen påverkade en enda tabell. EF8 tar bort den här begränsningen. Tänk dig till exempel en entitetstyp Customer med CustomerInfo den ägda typen:

public class Customer
{
    public int Id { get; set; }
    public required string Name { get; set; }
    public required CustomerInfo CustomerInfo { get; set; }
}

[Owned]
public class CustomerInfo
{
    public string? Tag { get; set; }
}

Båda dessa entitetstyper mappas till Customers-tabellen. Följande massuppdatering misslyckas dock på EF7 eftersom den använder båda entitetstyperna:

await context.Customers
    .Where(e => e.Name == name)
    .ExecuteUpdateAsync(
        s => s.SetProperty(b => b.CustomerInfo.Tag, "Tagged")
            .SetProperty(b => b.Name, b => b.Name + "_Tagged"));

I EF8 översätts detta nu till följande SQL när du använder Azure SQL:

UPDATE [c]
SET [c].[Name] = [c].[Name] + N'_Tagged',
    [c].[CustomerInfo_Tag] = N'Tagged'
FROM [Customers] AS [c]
WHERE [c].[Name] = @__name_0

På samma sätt kan instanser som returneras från en Union fråga uppdateras så länge alla uppdateringar riktar sig mot samma tabell. Vi kan till exempel uppdatera alla Customer med regionen France, och samtidigt uppdatera alla Customer som har besökt en butik i regionen France:

await context.CustomersWithStores
    .Where(e => e.Region == "France")
    .Union(context.Stores.Where(e => e.Region == "France").SelectMany(e => e.Customers))
    .ExecuteUpdateAsync(s => s.SetProperty(b => b.Tag, "The French Connection"));

I EF8 genererar den här frågan följande när du använder Azure SQL:

UPDATE [c]
SET [c].[Tag] = N'The French Connection'
FROM [CustomersWithStores] AS [c]
INNER JOIN (
    SELECT [c0].[Id], [c0].[Name], [c0].[Region], [c0].[StoreId], [c0].[Tag]
    FROM [CustomersWithStores] AS [c0]
    WHERE [c0].[Region] = N'France'
    UNION
    SELECT [c1].[Id], [c1].[Name], [c1].[Region], [c1].[StoreId], [c1].[Tag]
    FROM [Stores] AS [s]
    INNER JOIN [CustomersWithStores] AS [c1] ON [s].[Id] = [c1].[StoreId]
    WHERE [s].[Region] = N'France'
) AS [t] ON [c].[Id] = [t].[Id]

Som ett sista exempel i EF8 ExecuteUpdate kan användas för att uppdatera entiteter i en TPT-hierarki så länge alla uppdaterade egenskaper mappas till samma tabell. Tänk dig till exempel de här entitetstyperna som mappats med TPT:

[Table("TptSpecialCustomers")]
public class SpecialCustomerTpt : CustomerTpt
{
    public string? Note { get; set; }
}

[Table("TptCustomers")]
public class CustomerTpt
{
    public int Id { get; set; }
    public required string Name { get; set; }
}

Med EF8 kan egenskapen Note uppdateras:

await context.TptSpecialCustomers
    .Where(e => e.Name == name)
    .ExecuteUpdateAsync(s => s.SetProperty(b => b.Note, "Noted"));

Eller så kan egenskapen Name uppdateras:

await context.TptSpecialCustomers
    .Where(e => e.Name == name)
    .ExecuteUpdateAsync(s => s.SetProperty(b => b.Name, b => b.Name + " (Noted)"));

EF8 misslyckas dock med att uppdatera både Name egenskaperna och Note eftersom de mappas till olika tabeller. Till exempel:

await context.TptSpecialCustomers
    .Where(e => e.Name == name)
    .ExecuteUpdateAsync(s => s.SetProperty(b => b.Note, "Noted")
        .SetProperty(b => b.Name, b => b.Name + " (Noted)"));

Genererar följande undantag:

The LINQ expression 'DbSet<SpecialCustomerTpt>()
    .Where(s => s.Name == __name_0)
    .ExecuteUpdate(s => s.SetProperty<string>(
        propertyExpression: b => b.Note,
        valueExpression: "Noted").SetProperty<string>(
        propertyExpression: b => b.Name,
        valueExpression: b => b.Name + " (Noted)"))' could not be translated. Additional information: Multiple 'SetProperty' invocations refer to different tables ('b => b.Note' and 'b => b.Name'). A single 'ExecuteUpdate' call can only update the columns of a single table. See https://go.microsoft.com/fwlink/?linkid=2101038 for more information.

Bättre användning av IN frågor

När Contains LINQ-operatorn används med en underfråga genererar EF Core nu bättre frågor med hjälp av SQL IN istället för EXISTS; förutom att producera mer läsbar SQL kan detta i vissa fall resultera i dramatiskt snabbare frågor. Tänk till exempel på följande LINQ-fråga:

var blogsWithPosts = await context.Blogs
    .Where(b => context.Posts.Select(p => p.BlogId).Contains(b.Id))
    .ToListAsync();

EF7 genererar följande för PostgreSQL:

SELECT b."Id", b."Name"
      FROM "Blogs" AS b
      WHERE EXISTS (
          SELECT 1
          FROM "Posts" AS p
          WHERE p."BlogId" = b."Id")

Eftersom underfrågan refererar till den externa Blogs tabellen (via b."Id") är detta en korrelerad underfråga, vilket innebär att Posts underfrågan måste köras för varje rad i Blogs tabellen. I EF8 genereras följande SQL i stället:

SELECT b."Id", b."Name"
      FROM "Blogs" AS b
      WHERE b."Id" IN (
          SELECT p."BlogId"
          FROM "Posts" AS p
      )

Eftersom underfrågan inte längre refererar till Blogskan den utvärderas en gång, vilket ger enorma prestandaförbättringar på de flesta databassystem. Vissa databassystem, framför allt SQL Server, kan dock optimera den första frågan till den andra frågan så att prestandan är densamma.

Numeriska radversioner för SQL Azure/SQL Server

AUTOMATISK optimistisk samtidighet i SQL Server hanteras med hjälp av rowversion kolumner. A rowversion är ett ogenomskinliga 8 byte-värde som skickas mellan databas, klient och server. Som standard exponerar SqlClient rowversion typer som byte[], även om föränderliga referenstyper inte passar bra med rowversion semantik. I EF8 är det enkelt att i stället mappa rowversion kolumner till long eller ulong egenskaper. Till exempel:

modelBuilder.Entity<Blog>()
    .Property(e => e.RowVersion)
    .IsRowVersion();

Parenteser eliminering

Att generera läsbar SQL är ett viktigt mål för EF Core. I EF8 är den genererade SQL:en mer läsbar genom automatisk eliminering av onödig parentes. Till exempel följande LINQ-fråga:

await ctx.Customers  
    .Where(c => c.Id * 3 + 2 > 0 && c.FirstName != null || c.LastName != null)  
    .ToListAsync();  

Översätts till följande Azure SQL när du använder EF7:

SELECT [c].[Id], [c].[City], [c].[FirstName], [c].[LastName], [c].[Street]
FROM [Customers] AS [c]
WHERE ((([c].[Id] * 3) + 2) > 0 AND ([c].[FirstName] IS NOT NULL)) OR ([c].[LastName] IS NOT NULL)

Som har förbättrats till följande när du använder EF8:

SELECT [c].[Id], [c].[City], [c].[FirstName], [c].[LastName], [c].[Street]
FROM [Customers] AS [c]
WHERE ([c].[Id] * 3 + 2 > 0 AND [c].[FirstName] IS NOT NULL) OR [c].[LastName] IS NOT NULL

Specifik undantag för RETURNING/OUTPUT-sats

EF7 ändrade standard-SQL för uppdatering till att använda RETURNING/OUTPUT för att hämta tillbaka databasgenererade kolumner. Vissa fall har identifierats där detta inte fungerar, därför introducerar EF8 explicita avsteg för det här beteendet.

Du kan till exempel välja bort OUTPUT när du använder SQL Server/Azure SQL-providern:

 modelBuilder.Entity<Customer>().ToTable(tb => tb.UseSqlOutputClause(false));

Eller om du vill välja bort RETURNING när du använder SQLite-providern:

 modelBuilder.Entity<Customer>().ToTable(tb => tb.UseSqlReturningClause(false));

Andra mindre ändringar

Utöver de förbättringar som beskrivs ovan har många mindre ändringar gjorts i EF8. Detta omfattar: