Hierarchická data ve zprostředkovateli SQL Server EF Core

Note

Tato funkce byla přidána v EF Core 8.0.

Azure SQL a SQL Server mají speciální datový typ, hierarchyid který se používá k ukládání hierarchických dat. V tomto případě "hierarchická data" v podstatě znamenají data, která tvoří strukturu stromu, kde každá položka může mít nadřazený objekt nebo podřízené položky. Mezi příklady těchto dat patří:

  • Organizační struktura
  • Systém souborů
  • Sada úkolů v projektu
  • Taxonomie jazykových termínů
  • Graf odkazů mezi webovými stránkami

Databáze pak může spouštět dotazy na tato data pomocí své hierarchické struktury. Dotaz může například najít nadřazené položky a závislé položky nebo najít všechny položky v určité hloubkě v hierarchii.

Použití HierarchyId v .NET a EF Core

Na nejnižší úrovni obsahuje balíček NuGet Microsoft.SqlServer.Types typ s názvem SqlHierarchyId. I když tento typ podporuje hodnoty typu hierarchyid, je trochu těžkopádné pracovat s LINQ.

Na další úrovni byl zaveden nový balíček Microsoft.EntityFrameworkCore.SqlServer.Abstractions , který obsahuje typ vyšší úrovně HierarchyId určený pro použití v typech entit.

Tip

Typ HierarchyId je idiomatičtější pro normy .NET než SqlHierarchyId, který je naopak modelován podle toho, jak jsou typy .NET Framework hostovány uvnitř databázového stroje SQL Server. HierarchyId je navržený tak, aby fungoval s EF Core, ale dá se použít i mimo EF Core v jiných aplikacích. Balíček Microsoft.EntityFrameworkCore.SqlServer.Abstractions neodkazuje na žádné další balíčky, a proto má minimální dopad na nasazenou velikost a závislosti aplikace.

HierarchyId Použití funkcí EF Core, jako jsou dotazy a aktualizace, vyžaduje balíček Microsoft.EntityFrameworkCore.SqlServer.HierarchyId. Tento balíček zahrnuje Microsoft.EntityFrameworkCore.SqlServer.Abstractions a Microsoft.SqlServer.Types jako tranzitivní závislosti, a tak je často jediným potřebným balíčkem.

dotnet add package Microsoft.EntityFrameworkCore.SqlServer.HierarchyId

Po instalaci balíčku je použití HierarchyId povoleno voláním UseHierarchyId v rámci volání UseSqlServeraplikace . Například:

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

Hierarchie modelování

Typ HierarchyId lze použít pro vlastnosti typu entity. Předpokládejme například, že chceme modelovat otcovskou větev rodokmenu některých fiktivních napůlčíků. V typu entity pro Halfling lze vlastnost HierarchyId použít k vyhledání každého půlčíka v rodinném stromu.

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

Kód uvedený zde a v následujících příkladech pochází z HierarchyIdSample.cs.

Tip

V případě potřeby HierarchyId je vhodný pro použití jako typ klíčové vlastnosti.

V tomto případě má rodinný strom svůj počátek u patriarchy rodiny. Každý půlčík lze sledovat od patriarchy po linii směrem dolů pomocí jeho PathFromPatriarch vlastnosti. SQL Server používá pro tyto cesty kompaktní binární formát, ale při práci s kódem je běžné parsovat na a z řetězcové reprezentace čitelné lidmi. V této reprezentaci je pozice na každé úrovni oddělena znakem / . Představte si například rodinný strom v následujícím diagramu:

Poločasový rodokmen

V tomto stromu:

  • Balbo je u kořene stromu, reprezentován /.
  • Balbo má pět dětí reprezentovaných /1/, /2/, /3/, /4/a /5/.
  • První dítě Balbo, Mungo, má také pět dětí, reprezentované /1/1/, /1/2/, /1/3/, /1/4/a /1/5/. Všimněte si, že HierarchyId pro Mungo (/1/) je předpona pro všechny jeho děti.
  • Podobně Balboho třetí dítě, Ponto, má dvě děti, reprezentované /3/1/ a /3/2/. Každý z těchto dětí je opět předponován HierarchyId pro Ponto, který je reprezentován jako /3/.
  • A tak dál dolů po stromě...

Následující kód vloží tento rodinný strom do databáze pomocí 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

V případě potřeby lze desetinné hodnoty použít k vytvoření nových uzlů mezi dvěma existujícími uzly. Například /3/2.5/2/ jde mezi /3/2/2/ a /3/3/2/.

Dotazování hierarchií

HierarchyId zveřejňuje několik metod, které lze použít v dotazech LINQ.

Method Description
GetAncestor(int n) Získá úroveň uzlu n o několik úrovní výše v hierarchickém stromě.
GetDescendant(HierarchyId? child1, HierarchyId? child2) Získá hodnotu potomkového uzlu, který je větší než child1 a menší než child2.
GetLevel() Získá úroveň tohoto uzlu v hierarchickém stromu.
GetReparentedValue(HierarchyId? oldRoot, HierarchyId? newRoot) Získá hodnotu představující umístění nového uzlu, který má stejnou cestu jako cesta od newRoot k oldRoot k tomuto uzlu, čímž se efektivně přemístí na nové místo.
IsDescendantOf(HierarchyId? parent) Získá hodnotu určující, zda tento uzel je potomkem parent.

Kromě toho lze operátory ==, , !=<, <=> a >= lze použít.

Tady jsou příklady použití těchto metod v dotazech LINQ.

Získání entit na dané úrovni ve stromu

Následující dotaz používá GetLevel k vrácení všech půlčíků na dané úrovni v rodinném stromu.

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

To se přeloží na následující SQL:

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

Když to spustíme ve smyčce, můžeme získat poloviny pro každou generaci:

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

Získejte přímého předka entity

Následující dotaz používá GetAncestor k vyhledání přímého předka půlčíka, pokud znáte jméno půlčíka:

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

To se přeloží na následující 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)

Spuštění tohoto dotazu pro polovinu "Bilbo" vrátí "Bungo".

Získejte přímé potomky entity

Následující dotaz také používá GetAncestor, ale tentokrát k vyhledání přímých potomků halflinga, s ohledem na jméno toho halflinga.

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

To se přeloží na následující 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)

Spuštění tohoto dotazu pro hobita "Mungo" vrátí "Bungo", "Belba", "Longo" a "Linda".

Získejte všechny předky entity

GetAncestor je užitečné pro vyhledávání nahoru nebo dolů o jednu úroveň, nebo o stanovený počet úrovní. Na druhé straně IsDescendantOf je užitečné pro vyhledání všech předků nebo závislých. Například následující dotaz používá IsDescendantOf k vyhledání všech předků půlčíka na základě jeho jména.

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 vrátí hodnotu true pro sebe, což je důvod, proč je filtrován v dotazu výše.

To se přeloží na následující 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

Spuštění tohoto dotazu pro půlčíka "Bilbo" vrátí "Bungo", "Mungo" a "Balbo".

Získání všech potomků entity

Následující dotaz také používá IsDescendantOf, ale tentokrát ke všem potomkům půlčíka, kterým je dáno jméno toho půlčíka:

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());

To se přeloží na následující 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()

Spuštění tohoto dotazu pro hobita "Mungo" vrátí "Bungo", "Belba", "Longo", "Linda", "Bingo", "Bilbo", "Otho", "Falco", "Lotho" a "Poppy".

Nalezení společného předka

Jednou z nejčastějších otázek týkajících se tohoto konkrétního rodokmenu je: "Kdo je společným předkem Froda a Bilba?" K zápisu takového dotazu můžeme použít IsDescendantOf.

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();

To se přeloží na následující 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

Spuštění tohoto dotazu s "Bilbo" a "Frodo" nám sděluje, že jejich společný předek je "Balbo".

Aktualizace hierarchií

K aktualizaci sloupců lze použít normální mechanismy sledování změn a uložení změn.

Přerodování pod-hierarchie

Jsem si například jistý, že všichni pamatujeme na skandál SR 1752 (a.k.a. "LongoGate"), když DNA testování ukázalo, že Longo nebyl syn Mungo, ale ve skutečnosti syn Ponto! Jedním z pádů z tohoto skandálu bylo, že rodinný strom musel být znovu napsán. Zejména Longo a všichni jeho potomci museli být přeřazeni od Mungo na Ponto. GetReparentedValue se dá použít k tomu. Například první "Longo" a všechny jeho potomky jsou dotazovány:

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

Pak GetReparentedValue se používá k aktualizaci HierarchyId pro Longo a každého potomka, následované voláním SaveChangesAsync:

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

await context.SaveChangesAsync();

Výsledkem je následující aktualizace databáze:

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;

Pomocí těchto parametrů:

 @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

Hodnoty parametrů vlastností HierarchyId se posílají do databáze v kompaktním binárním formátu.

Po aktualizaci dotaz na potomky "Mungo" vrátí "Bungo", "Belba", "Linda", "Bingo", "Bilbo", "Falco" a "Poppy", zatímco dotaz na potomky "Ponto" vrátí "Longo", "Rosa", "Polo", "Otho", "Posco", "Prisca", "Lotho", "Ponto", "Porto", "Peony" a "Angelica".

Mapování funkcí

platforma .NET SQL
hierarchyId.GetAncestor(n) @hierarchyId.GetAncestor(@n)
hierarchyId.GetDescendant(child) @hierarchyId.GetDescendant(@child, NULL)
hierarchyId.GetDescendant(child1, child2) @hierarchyId.GetDescendant(@child1, @child2)
hierarchyId.GetLevel() @hierarchyId.GetLevel()
hierarchyId.GetReparentedValue(staráKořen, nováKořen) @hierarchyId.GetReparentedValue(@oldRoot, @newRoot)
HierarchyId.GetRoot() hierarchyid::GetRoot()
hierarchyId.IsDescendantOf(parent) @hierarchyId.IsDescendantOf(@parent)
HierarchyId.Parse(input) hierarchyid::Parse(@input)
hierarchyId.ToString() @hierarchyId.ToString()

Dodatečné zdroje