Condividi tramite


Dati gerarchici nel provider EF Core di SQL Server

Nota

Questa funzionalità è stata aggiunta in EF Core 8.0.

Azure SQL e SQL Server hanno un tipo di dati speciale denominato hierarchyid usato per archiviare dati gerarchici. In questo caso, "dati gerarchici" significa essenzialmente i dati che costituiscono una struttura ad albero, in cui ogni elemento può avere un elemento padre e/o figlio. Tra questi dati sono incluse:

  • Una struttura organizzativa
  • Un file system
  • Un set di attività di un progetto
  • Una tassonomia di termini del linguaggio
  • Un grafico di collegamenti tra pagine Web

Il database è quindi in grado di eseguire query su questi dati usando la relativa struttura gerarchica. Ad esempio, una query può trovare predecessori e dipendenti di elementi specificati o trovare tutti gli elementi in una certa profondità nella gerarchia.

Uso di HierarchyId in .NET ed EF Core

Al livello più basso, il pacchetto NuGet Microsoft.SqlServer.Types include un tipo denominato SqlHierarchyId. Anche se questo tipo supporta valori hierarchyid funzionanti, è un po' complesso da usare in LINQ.

Al livello successivo è stato introdotto un nuovo pacchetto Microsoft.EntityFrameworkCore.SqlServer.Abstractions, che include un tipo di livello HierarchyId superiore destinato all'uso nei tipi di entità.

Suggerimento

Il HierarchyId tipo è più idiotico rispetto alle norme di .NET rispetto a SqlHierarchyId, che viene invece modellato dopo il modo in cui i tipi .NET Framework sono ospitati all'interno del motore di database di SQL Server. HierarchyId è progettato per funzionare con EF Core, ma può essere usato anche all'esterno di EF Core in altre applicazioni. Il pacchetto Microsoft.EntityFrameworkCore.SqlServer.Abstractions non fa riferimento ad altri pacchetti e quindi ha un impatto minimo sulle dimensioni e sulle dipendenze delle applicazioni distribuite.

L'uso di per le funzionalità di HierarchyId EF Core, ad esempio query e aggiornamenti, richiede il pacchetto Microsoft.EntityFrameworkCore.SqlServer.HierarchyId. Questo pacchetto inserisce Microsoft.EntityFrameworkCore.SqlServer.Abstractions e Microsoft.SqlServer.Types come dipendenze transitive e quindi è spesso l'unico pacchetto necessario.

dotnet add package Microsoft.EntityFrameworkCore.SqlServer.HierarchyId

Dopo aver installato il pacchetto, l'uso di HierarchyId viene abilitato chiamando UseHierarchyId come parte della chiamata dell'applicazione a UseSqlServer. Ad esempio:

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

Gerarchie di modellazione

Il tipo HierarchyId può essere usato per le proprietà di un tipo di entità. Si supponga, ad esempio, di voler modellare l'albero della famiglia paterna di alcuni halfing fittizi. Nel tipo di entità per Halfling, una HierarchyId proprietà può essere usata per individuare ogni halfling nell'albero di famiglia.

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

Suggerimento

Il codice illustrato qui e negli esempi seguenti proviene da HierarchyIdSample.cs.

Suggerimento

Se necessario, HierarchyId è adatto per l'uso come tipo di proprietà chiave.

In questo caso, l'albero della famiglia è radicato con il patriarca della famiglia. Ogni halfling può essere tracciato dal patriarca verso il basso l'albero usando la sua proprietà PathFromPatriarch. SQL Server usa un formato binario compatto per questi percorsi, ma è comune analizzare da e verso una rappresentazione di stringa leggibile quando si usa codice. In questa rappresentazione, la posizione a ogni livello è separata da un carattere /. Si consideri ad esempio l'albero della famiglia nel diagramma seguente:

Albero della famiglia halfling

In questo albero:

  • Balbo si trova nella radice dell'albero, rappresentato da /.
  • Balbo ha cinque figli, rappresentati da /1/, /2/, /3//4/ e /5/.
  • Il primo figlio di Balbo, Mungo, ha anche cinque figli, rappresentati da /1/1/, /1/2//1/3/, /1/4/ e /1/5/. Si noti che il HierarchyId per Mungo (/1/) è il prefisso per tutti i suoi figli.
  • Analogamente, il terzo figlio di Balbo, Ponto, ha due figli, rappresentati da /3/1/ e /3/2/. Anche in questo caso, ognuno di questi figli è preceduto da HierarchyId per Ponto, rappresentato come /3/.
  • E così via giù per l'albero...

Il codice seguente inserisce questo albero di famiglia in un database usando 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();

Suggerimento

Se necessario, i valori decimali possono essere usati per creare nuovi nodi tra due nodi esistenti. Ad esempio, /3/2.5/2/ va tra /3/2/2/ e /3/3/2/.

Esecuzione di query sulle gerarchie

HierarchyId espone diversi metodi che possono essere usati nelle query LINQ.

metodo Descrizione
GetAncestor(int n) Ottiene i livelli del nodo n fino all'albero gerarchico.
GetDescendant(HierarchyId? child1, HierarchyId? child2) Ottiene il valore di un nodo discendente maggiore di child1 e minore di child2.
GetLevel() Ottiene il livello di questo nodo nell'albero gerarchico.
GetReparentedValue(HierarchyId? oldRoot, HierarchyId? newRoot) Ottiene un valore che rappresenta la posizione di un nuovo nodo con un percorso da newRoot uguale al percorso da oldRoot a questo, spostando in modo efficace il percorso nella nuova posizione.
IsDescendantOf(HierarchyId? parent) Ottiene un valore che indica se il nodo è un discendente di parent.

Inoltre, gli operatori ==, !=<, <=> e >= possono essere usati.

Di seguito sono riportati esempi di utilizzo di questi metodi nelle query LINQ.

Ottenere entità a un determinato livello nell'albero

La query seguente usa GetLevel per restituire tutti i halfling a un determinato livello nell'albero di famiglia:

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

Questo comportamento si traduce nel codice SQL seguente:

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

L'esecuzione di questa operazione in un ciclo consente di ottenere i halfling per ogni generazione:

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

Ottenere il predecessore diretto di un'entità

La query seguente usa GetAncestor per trovare il predecessore diretto di un halfling, dato il nome di halfling:

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

Questo comportamento si traduce nel codice SQL seguente:

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)

L'esecuzione di questa query per il halfling "Bilbo" restituisce "Bungo".

Ottenere i discendenti diretti di un'entità

La query seguente usa anche GetAncestor, ma questa volta per trovare i discendenti diretti di un halfling, dato il nome di halfling:

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

Questo comportamento si traduce nel codice SQL seguente:

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)

L'esecuzione di questa query per il halfling "Mungo" restituisce "Bungo", "Belba", "Longo" e "Linda".

Ottenere tutti i predecessori di un'entità

GetAncestor è utile per la ricerca su o verso il basso di un singolo livello, o, in effetti, un numero specificato di livelli. D'altra parte, IsDescendantOf è utile per trovare tutti i predecessori o dipendenti. Ad esempio, la query seguente usa IsDescendantOf per trovare tutti i predecessori di un halfling, dato il nome di halfling:

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

Importante

IsDescendantOf restituisce true per se stesso, motivo per cui viene filtrato nella query precedente.

Questo comportamento si traduce nel codice SQL seguente:

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

L'esecuzione di questa query per il halfling "Bilbo" restituisce "Bungo", "Mungo" e "Balbo".

Ottenere tutti i discendenti di un'entità

La query seguente usa anche IsDescendantOf, ma questa volta per trovare tutti i discendenti diretti di un halfling, dato il nome di halfling:

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

Questo comportamento si traduce nel codice SQL seguente:

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

L'esecuzione di questa query per il halfling "Mungo" restituisce "Bungo", "Belba", "Longo", "Linda", "Tomb", "Bilbo", "Otho", "Falco", "Lotho" e "Poppy".

Ricerca di un predecessore comune

Una delle domande più comuni poste su questo particolare albero di famiglia è: "chi è l'antenato comune di Frodo e Bilbo?" È possibile usare IsDescendantOf per scrivere una query di questo tipo:

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

Questo comportamento si traduce nel codice SQL seguente:

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

L'esecuzione di questa query con "Bilbo" e "Frodo" indica che il loro predecessore comune è "Balbo".

Aggiornamento delle gerarchie

Il normale rilevamento delle modifiche e i meccanismi SaveChanges possono essere usati per aggiornare le hierarchyid colonne.

Ri-parenting di una gerarchia secondaria

Ad esempio, sono sicuro che tutti ricordiamo lo scandalo della SR 1752 (a.k.a. "LongoGate") quando il test del DNA ha rivelato che Longo non era in realtà il figlio di Mungo, ma in realtà il figlio di Ponto! Una caduta da questo scandalo era che l'albero della famiglia doveva essere riscritto. In particolare, Longo e tutti i suoi discendenti dovevano essere ri-parented da Mungo a Ponto. GetReparentedValuepuò esser usato per svolgere questa azione. Ad esempio, vengono eseguite query su "Longo" e tutti i relativi discendenti:

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

Viene quindi GetReparentedValue usato per aggiornare HierarchyId per Longo e ogni discendente, seguito da una chiamata a SaveChangesAsync:

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

await context.SaveChangesAsync();

Questo si traduce nel seguente aggiornamento dei database:

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;

Uso di questi parametri:

 @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)

Nota

I valori dei parametri per le proprietà HierarchyId vengono inviati al database nel formato binario compatto.

Dopo l'aggiornamento, l'esecuzione di query per i discendenti di "Mungo" restituisce "Bungo", "Belba", "Linda", "Tomb", "Bilbo", "Falco" e "Poppy", mentre si esegue una query per i discendenti di "Ponto" restituisce "Longo", "Rosa", "Polo", "Otho", "Posco", "Prisca", "Lotho", "Ponto", "Porto", "Peony" e "Angelica".

Mapping delle funzioni

.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(oldRoot, newRoot) @hierarchyId.GetReparentedValue(@oldRoot, @newRoot)
HierarchyId.GetRoot() hierarchyid::GetRoot()
hierarchyId.IsDescendantOf(parent) @hierarchyId.IsDescendantOf(@parent)
HierarchyId.Parse(input) hierarchyid::Parse(@input)
hierarchyId.ToString() @hierarchyId.ToString()

Risorse aggiuntive