web-dev-qa-db-fra.com

Méthode la plus efficace d'arborescence d'auto-référencement utilisant Entity Framework

J'ai donc une table SQL qui est fondamentalement 

ID, ParentID, MenuName, [Lineage, Depth]

Les deux dernières colonnes sont automatiquement calculées pour aider à la recherche afin que nous puissions les ignorer pour le moment. 

Je crée un système de menu déroulant avec plusieurs catégories. 

Malheureusement, EF ne joue pas bien avec les tables de référencement auto-référencées de plus d'un niveau. Donc je suis parti avec quelques options

1) Créez une requête, triez par profondeur, puis créez une classe personnalisée en C #, en la renseignant une profondeur à la fois. 

2) Trouvez un moyen de charger les données avec impatience dans EF. Je ne pense pas que ce soit possible pour un nombre illimité de niveaux, seulement un montant fixe. 

3) D'une autre manière, je ne suis même pas sûr de. 

Toutes les contributions seraient les bienvenues! 

33
John Mitchell

J'ai réussi à mapper des données hiérarchiques en utilisant EF.

Prenons par exemple une entité Establishment. Cela peut représenter une entreprise, une université ou une autre unité au sein d’une structure organisationnelle plus vaste:

public class Establishment : Entity
{
    public string Name { get; set; }
    public virtual Establishment Parent { get; set; }
    public virtual ICollection<Establishment> Children { get; set; }
    ...
}

Voici comment les propriétés Parent/Children sont mappées. Ainsi, lorsque vous définissez l'entité Parent of 1, la collection Children de l'entité Parent est automatiquement mise à jour:

// ParentEstablishment 0..1 <---> * ChildEstablishment
HasOptional(d => d.Parent)
    .WithMany(p => p.Children)
    .Map(d => d.MapKey("ParentId"))
    .WillCascadeOnDelete(false); // do not delete children when parent is deleted

Notez que jusqu'à présent, je n'ai pas inclus vos propriétés de lignage ou de profondeur. Vous avez raison, EF ne fonctionne pas bien pour générer des requêtes hiérarchiques imbriquées avec les relations ci-dessus. Ce que j’ai finalement choisi, c’est l’ajout d’une nouvelle entité gérondif, ainsi que de 2 nouvelles propriétés d’entité:

public class EstablishmentNode : Entity
{
    public int AncestorId { get; set; }
    public virtual Establishment Ancestor { get; set; }

    public int OffspringId { get; set; }
    public virtual Establishment Offspring { get; set; }

    public int Separation { get; set; }
}

public class Establishment : Entity
{
    ...
    public virtual ICollection<EstablishmentNode> Ancestors { get; set; }
    public virtual ICollection<EstablishmentNode> Offspring { get; set; }

}

En écrivant cela, hazzik a posté une réponse qui ressemble beaucoup à cette approche . Je continuerai cependant à vous proposer une alternative légèrement différente. J'aime faire de mes types d'entité réels mes types gerund Ancestor et Offspring car cela m'aide à obtenir la séparation entre l'ancêtre et la progéniture (ce que vous avez appelé profondeur). Voici comment je les ai cartographiées:

private class EstablishmentNodeOrm : EntityTypeConfiguration<EstablishmentNode>
{
    internal EstablishmentNodeOrm()
    {
        ToTable(typeof(EstablishmentNode).Name);
        HasKey(p => new { p.AncestorId, p.OffspringId });
    }
}

... et enfin, les relations d'identification dans l'entité Établissement:

// has many ancestors
HasMany(p => p.Ancestors)
    .WithRequired(d => d.Offspring)
    .HasForeignKey(d => d.OffspringId)
    .WillCascadeOnDelete(false);

// has many offspring
HasMany(p => p.Offspring)
    .WithRequired(d => d.Ancestor)
    .HasForeignKey(d => d.AncestorId)
    .WillCascadeOnDelete(false);

De plus, je n'ai pas utilisé de sproc pour mettre à jour les mappages de nœuds. À la place, nous avons un ensemble de commandes internes qui dériveront/calculeront les propriétés Ancestors et Offspring en fonction des propriétés Parent & Children. Cependant, vous finissez par être capable de faire des requêtes très similaires à celles de la réponse de hazzik:

// load the entity along with all of its offspring
var establishment = dbContext.Establishments
    .Include(x => x.Offspring.Select(y => e.Offspring))
    .SingleOrDefault(x => x.Id == id);

La raison de l'entité de pont entre l'entité principale et ses ancêtres/descendants est encore une fois parce que cette entité vous permet d'obtenir la séparation. De plus, en le déclarant comme une relation d'identification, vous pouvez supprimer des nœuds de la collection sans avoir à appeler explicitement DbContext.Delete ().

// load all entities that are more than 3 levels deep
var establishments = dbContext.Establishments
    .Where(x => x.Ancestors.Any(y => y.Separation > 3));
35
danludwig

Vous pouvez utiliser une table hiérarchique pour effectuer un chargement rapide de niveaux illimités d’arbres.

Donc, vous devez ajouter deux collections Ancestors et Descendants, les deux collections doivent être mappées plusieurs à plusieurs dans la table de support. 

public class Tree 
{
    public virtual Tree Parent { get; set; }
    public virtual ICollection<Tree> Children { get; set; }
    public virtual ICollection<Tree> Ancestors { get; set; }
    public virtual ICollection<Tree> Descendants { get; set; }
}

Les ancêtres contiendront tous les ancêtres (parent, grand-parent, grand-grand-parent, etc.) de l'entité et Descendants contiendra tous les descendants (enfants, petits-enfants, petits-enfants, etc.) de l'entité.

Maintenant, vous devez le mapper avec EF Code First:

public class TreeConfiguration : EntityTypeConfiguration<Tree>
{
    public TreeConfiguration()
    {
        HasOptional(x => x.Parent)
            .WithMany(x => x.Children)
            .Map(m => m.MapKey("PARENT_ID"));

        HasMany(x => x.Children)
            .WithOptional(x => x.Parent);

        HasMany(x => x.Ancestors)
            .WithMany(x => x.Descendants)
            .Map(m => m.ToTable("Tree_Hierarchy").MapLeftKey("PARENT_ID").MapRightKey("CHILD_ID"));

        HasMany(x => x.Descendants)
            .WithMany(x => x.Ancestors)
            .Map(m => m.ToTable("Tree_Hierarchy").MapLeftKey("CHILD_ID").MapRightKey("PARENT_ID"));
    }    
}

Maintenant, avec cette structure, vous pouvez faire chercher chercher comme suit

context.Trees.Include(x => x.Descendants).Where(x => x.Id == id).SingleOrDefault()

Cette requête chargera l'entité avec id et tous ses descendants.

Vous pouvez remplir la table de support avec la procédure stockée suivante:

CREATE PROCEDURE [dbo].[FillHierarchy] (@table_name nvarchar(MAX), @hierarchy_name nvarchar(MAX))
AS
BEGIN
    DECLARE @sql nvarchar(MAX), @id_column_name nvarchar(MAX)
    SET @id_column_name = '[' + @table_name + '_ID]'
    SET @table_name = '[' + @table_name + ']'
    SET @hierarchy_name = '[' + @hierarchy_name + ']'

    SET @sql = ''
    SET @sql = @sql + 'WITH Hierachy(CHILD_ID, PARENT_ID) AS ( '
    SET @sql = @sql + 'SELECT ' + @id_column_name + ', [PARENT_ID] FROM ' + @table_name + ' e '
    SET @sql = @sql + 'UNION ALL '
    SET @sql = @sql + 'SELECT e.' + @id_column_name + ', e.[PARENT_ID] FROM ' + @table_name + ' e '
    SET @sql = @sql + 'INNER JOIN Hierachy eh ON e.' + @id_column_name + ' = eh.[PARENT_ID]) '
    SET @sql = @sql + 'INSERT INTO ' + @hierarchy_name + ' ([CHILD_ID], [PARENT_ID]) ( '
    SET @sql = @sql + 'SELECT [CHILD_ID], [PARENT_ID] FROM Hierachy WHERE [PARENT_ID] IS NOT NULL '
    SET @sql = @sql + ') '

    EXECUTE (@sql)
END
GO

Vous pouvez même mapper une table de support sur une vue:

CREATE VIEW [Tree_Hierarchy]
AS
    WITH Hierachy (CHILD_ID, PARENT_ID) 
    AS 
    (
        SELECT [MySuperTree_ID], [PARENT_ID] FROM [MySuperTree] AS e
        UNION ALL
        SELECT e.[MySuperTree_ID], e.[PARENT_ID] FROM [MySuperTree] AS e 
            INNER JOIN Hierachy AS eh ON e.[MySuperTree_ID] = eh.[PARENT_ID]
    )

    SELECT [CHILD_ID], [PARENT_ID] FROM Hierachy WHERE [PARENT_ID] IS NOT NULL
GO
12
hazzik

J'ai déjà passé un certain temps à essayer de corriger un bogue dans votre solution. La procédure stockée ne génère pas vraiment d'enfants, de petits-enfants, etc., ci-dessous vous trouverez une procédure stockée fixe:

CREATE PROCEDURE dbo.UpdateHierarchy AS
BEGIN
  DECLARE @sql nvarchar(MAX)

  SET @sql = ''
  SET @sql = @sql + 'WITH Hierachy(ChildId, ParentId) AS ( '
  SET @sql = @sql + 'SELECT t.Id, t.ParentId FROM dbo.Tree t '
  SET @sql = @sql + 'UNION ALL '
  SET @sql = @sql + 'SELECT h.ChildId, t.ParentId FROM dbo.Tree t '
  SET @sql = @sql + 'INNER JOIN Hierachy h ON t.Id = h.ParentId) '
  SET @sql = @sql + 'INSERT INTO dbo.TreeHierarchy (ChildId, ParentId) ( '
  SET @sql = @sql + 'SELECT DISTINCT ChildId, ParentId FROM Hierachy WHERE ParentId IS NOT NULL '
  SET @sql = @sql + 'EXCEPT SELECT t.ChildId, t.ParentId FROM dbo.TreeHierarchy t '
  SET @sql = @sql + ') '

  EXECUTE (@sql)
END

Erreur: mauvaise référence. Traduire le code @hazzik c'était:

  SET @sql = @sql + 'SELECT t.ChildId, t.ParentId FROM dbo.Tree t '

mais devrait être

  SET @sql = @sql + 'SELECT h.ChildId, t.ParentId FROM dbo.Tree t '

j'ai également ajouté du code qui vous permet de mettre à jour la table TreeHierarchy non seulement lorsque vous la peuplerez.

  SET @sql = @sql + 'EXCEPT SELECT t.ChildId, t.ParentId FROM dbo.TreeHierarchy t '

Et la magie. Cette procédure ou plutôt TreeHierarchy vous permet de charger des enfants simplement en incluant des ancêtres (pas des enfants et pas des descendants).

 using (var context = new YourDbContext())
 {
      rootNode = context.Tree
           .Include(x => x.Ancestors)
           .SingleOrDefault(x => x.Id == id);
 } 

Maintenant, YourDbContext renvoie un rootNode avec des enfants chargés, des enfants des enfants de rootName (petits-enfants), etc.

5
DeXteR

Une autre option d'implémentation sur laquelle j'ai récemment travaillé ... 

Mon arbre est très simple. 

public class Node
{
    public int NodeID { get; set; }
    public string Name { get; set; }
    public virtual Node ParentNode { get; set; }
    public int? ParentNodeID { get; set; }
    public virtual ICollection<Node> ChildNodes { get; set; }
    public int? LeafID { get; set; }
    public virtual Leaf Leaf { get; set; }
}
public class Leaf
{
    public int LeafID { get; set; }
    public string Name { get; set; }
    public virtual ICollection<Node> Nodes { get; set; }
}

Mes exigences, pas tellement. 

À partir d’un ensemble de feuilles et d’un seul ancêtre, montrez aux enfants de cet ancêtre dont les descendants ont des feuilles dans l’ensemble.

Une analogie serait une structure de fichier sur disque. L'utilisateur actuel a accès à un sous-ensemble de fichiers sur le système. Lorsque l'utilisateur ouvre des nœuds dans l'arborescence du système de fichiers, nous souhaitons uniquement montrer que les nœuds de l'utilisateur les mèneront éventuellement aux fichiers qu'ils peuvent voir. Nous ne voulons pas leur montrer les chemins d'accès aux fichiers auxquels ils n'ont pas accès (pour des raisons de sécurité, par exemple, en laissant filtrer l'existence d'un document d'un certain type).

Nous voulons pouvoir exprimer ce filtre sous la forme IQueryable<T> afin de pouvoir l'appliquer à n'importe quelle requête de nœud, en filtrant les résultats indésirables.

Pour ce faire, j'ai créé une fonction Table Valued qui renvoie les descendants d'un nœud de l'arborescence. Cela se fait via un CTE.

CREATE FUNCTION [dbo].[DescendantsOf]
(   
    @parentId int
)
RETURNS TABLE 
AS
RETURN 
(
    WITH descendants (NodeID, ParentNodeID, LeafID) AS(
        SELECT NodeID, ParentNodeID, LeafID from Nodes where ParentNodeID = @parentId
        UNION ALL
        SELECT n.NodeID, n.ParentNodeID, n.LeafID from Nodes n inner join descendants d on n.ParentNodeID = d.NodeID
    ) SELECT * from descendants
)

Maintenant, j'utilise le code d'abord, alors je devais utiliser 

https://www.nuget.org/packages/EntityFramework.Functions

afin d'ajouter la fonction à mon DbContext

[TableValuedFunction("DescendantsOf", "Database", Schema = "dbo")]
public IQueryable<NodeDescendant> DescendantsOf(int parentID)
{
    var param = new ObjectParameter("parentId", parentID);
    return this.ObjectContext().CreateQuery<NodeDescendant>("[DescendantsOf](@parentId)", param);
}

avec un type de retour complexe (impossible de réutiliser Node en examinant cela)

[ComplexType]
public class NodeDescendant
{
    public int NodeID { get; set; }
    public int LeafID { get; set; }
}

En réunissant tous les éléments, cela m'a permis, lorsque l'utilisateur développe un nœud dans l'arborescence, d'obtenir la liste filtrée des nœuds enfants.

public static Node[] GetVisibleDescendants(int parentId)
{
    using (var db = new Models.Database())
    {
        int[] visibleLeaves = SuperSecretResourceManager.GetLeavesForCurrentUserLol();

        var targetQuery = db.Nodes as IQueryable<Node>;

        targetQuery = targetQuery.Where(node =>
                node.ParentNodeID == parentId &&
                db.DescendantsOf(node.NodeID).Any(x => 
                                visibleLeaves.Any(y => x.LeafID == y)));

        // Notice, still an IQueryable.  Perform whatever processing is required.
        SortByCurrentUsersSavedSettings(targetQuery);

        return targetQuery.ToArray();
    }
}

Il est important de noter que la fonction est exécutée sur le serveur, pas dans l'application. Voici la requête qui est exécutée

SELECT 
    [Extent1].[NodeID] AS [NodeID], 
    [Extent1].[Name] AS [Name], 
    [Extent1].[ParentNodeID] AS [ParentNodeID], 
    [Extent1].[LeafID] AS [LeafID]
    FROM [dbo].[Nodes] AS [Extent1]
    WHERE ([Extent1].[ParentNodeID] = @p__linq__0) AND ( EXISTS (SELECT 
        1 AS [C1]
        FROM ( SELECT 
            [Extent2].[LeafID] AS [LeafID]
            FROM [dbo].[DescendantsOf]([Extent1].[NodeID]) AS [Extent2]
        )  AS [Project1]
        WHERE  EXISTS (SELECT 
            1 AS [C1]
            FROM  ( SELECT 1 AS X ) AS [SingleRowTable1]
            WHERE [Project1].[LeafID] = 17
        )
    ))

Notez l'appel de fonction dans la requête ci-dessus.

2
Will

Je savais qu'il devait y avoir quelque chose qui n'allait pas avec cette solution. Ce n'est pas simple En utilisant cette solution, EF6 nécessite un autre paquet de hacks pour gérer un simple arbre (par exemple des suppressions). J'ai donc finalement trouvé une solution simple mais combinée à cette approche.

Tout d’abord, laissez l’entité simple: il suffit d’un parent et d’une liste d’enfants. Aussi, la cartographie devrait être simple:

 HasOptional(x => x.Parent)
    .WithMany(x => x.Children)
    .Map(m => m.MapKey("ParentId"));

 HasMany(x => x.Children)
    .WithOptional(x => x.Parent);

Ajoutez ensuite la migration (code d'abord: migrations: console du package: hiérarchie Add-Migration) ou, d'une autre manière, une procédure stockée:

CREATE PROCEDURE [dbo].[Tree_GetChildren] (@Id int) AS
BEGIN
WITH Hierachy(ChildId, ParentId) AS (
    SELECT ts.Id, ts.ParentId 
        FROM med.MedicalTestSteps ts
    UNION ALL 
    SELECT h.ChildId, ts.ParentId 
        FROM med.MedicalTestSteps ts
        INNER JOIN Hierachy h ON ts.Id = h.ParentId
) 
SELECT h.ChildId
    FROM Hierachy h
    WHERE h.ParentId = @Id
END

Ensuite, lorsque vous essayez de recevoir vos noeuds d’arbre de la base de données, faites-le en deux étapes:

//Get children IDs
var sql = $"EXEC Tree_GetChildren {rootNodeId}";
var children = context.Database.SqlQuery<int>(sql).ToList<int>();

//Get root node and all it's children
var rootNode = _context.TreeNodes
                    .Include(s => s.Children)
                    .Where(s => s.Id == id || children.Any(c => s.Id == c))
                    .ToList() //MUST - get all children from database then get root
                    .FirstOrDefault(s => s.Id == id);

Tout ça. Cette requête vous aide à obtenir un nœud racine et à charger tous les enfants. Sans jouer avec l'introduction d'ancêtres et de descendants.

Rappelez-vous également quand vous allez essayer de sauvegarder un sous-noeud, puis procédez comme suit:

var node = new Node { ParentId = rootNode }; //Or null, if you want node become a root
context.TreeNodess.Add(node);
context.SaveChanges();

Faites-le de cette façon, pas en ajoutant des enfants au nœud racine.

2
DeXteR

@danludwig merci pour votre réponse

J'écris une fonction pour la mise à jour Node, ça marche parfaitement. Mon code est-il bon ou devrais-je l'écrire autrement?

    public void Handle(ParentChanged e)
    {
        var categoryGuid = e.CategoryId.Id;
        var category = _context.Categories
            .Include(cat => cat.ParentCategory)
            .First(cat => cat.Id == categoryGuid);

        if (null != e.OldParentCategoryId)
        {
            var oldParentCategoryGuid = e.OldParentCategoryId.Id;
            if (category.ParentCategory.Id == oldParentCategoryGuid)
            {
                throw new Exception("Old Parent Category mismatch.");
            }
        }

        (_context as DbContext).Configuration.LazyLoadingEnabled = true;

        RemoveFromAncestors(category, category.ParentCategory);

        var newParentCategoryGuid = e.NewParentCategoryId.Id;
        var parentCategory = _context.Categories
            .First(cat => cat.Id == newParentCategoryGuid);

        category.ParentCategory = parentCategory;

        AddToAncestors(category, category.ParentCategory, 1);

        _context.Commit();
    }

    private static void RemoveFromAncestors(Model.Category.Category mainCategory, Model.Category.Category ancestorCategory)
    {
        if (null == ancestorCategory)
        {
            return;
        }

        while (true)
        {
            var offspring = ancestorCategory.Offspring;
            offspring?.RemoveAll(node => node.OffspringId == mainCategory.Id);

            if (null != ancestorCategory.ParentCategory)
            {
                ancestorCategory = ancestorCategory.ParentCategory;
                continue;
            }
            break;
        }
    }

    private static int AddToAncestors(Model.Category.Category mainCategory,
        Model.Category.Category ancestorCategory, int deep)
    {
        var offspring = ancestorCategory.Offspring ?? new List<CategoryNode>();
        if (null == ancestorCategory.Ancestors)
        {
            ancestorCategory.Ancestors = new List<CategoryNode>();
        }

        var node = new CategoryNode()
        {
            Ancestor = ancestorCategory,
            Offspring = mainCategory
        };

        offspring.Add(node);

        if (null != ancestorCategory.ParentCategory)
        {
            deep = AddToAncestors(mainCategory, ancestorCategory.ParentCategory, deep + 1);
        }

        node.Separation = deep;

        return deep;
    }
0
SH_SWAT