3

If you look at the CoreUI live demo website, you'll see a navigation bar on the left-hand side, with multiple collapsible levels. I want to implement something like this, but dynamically. I created a simple class:

public class NavItem
{
    [Key]
    [DatabaseGenerated(DatabaseGeneratedOption.Identity)]
    public int navItemId { get; set; }
    public int sortOrder { get; set; }
    public bool isTitle { get; set; }
    public bool isDivider { get; set; }
    public string cssClass { get; set; }
    public string name { get; set; }
    public string url { get; set; }
    public string icon { get; set; }
    public string variant { get; set; }
    public string badgeText { get; set; }
    public string badgeVariant { get; set; }
    public virtual ICollection<NavItem> children { get; set; }
}

Note the ICollection<NavItem> children property.

Anyway, I populated this with a sample data set (the CoreUI example), and it correctly saves in the database, with a field called NavItemId1 storing the ID of the parent of any children. All good so far.

Now I'd like to query it, so I did the obvious:

var nI = db.navItems.ToList();

This, rather brilliantly, produces a list containing all of the nav items, with the children property correctly populated with children, where required.

However, it also included all of the child items at root level too... so instead of 15 root level items, I've got 40.

Is there a linq query I can run which will prevent the root level of the list from filling up with children (i.e. excluding any where the field NavItemId1 != null), but still correctly loading the rest of the structure?

e.g. this is what I get now:

  • root1
  • root2
    • child1-of-root2
    • child2-of-root2
      • child1-of-child2-of-root2
  • root3
    • child1-of-root3<-- I want my list to end here
  • child1-of-root2
  • child2-of-root2
    • child1-of-child2-of-root2
  • child1-of-child2-of-root2
  • child1-of-root3

I could add an isRoot boolean property, then run a post-read query to drop any items at root level which don't have isRoot set, e.g.

var nI = db.navItems.ToList();
nI = nI.Where(p => p.isRoot).ToList();

but that seems very bodgy.

Example code showing the problem. In particular look at items 6 and 16 (1st with 1 level of children, 2nd with 2 levels).

https://github.com/adev73/EFCore-LoadParentWithChildren-Example

Ade
  • 640
  • 1
  • 7
  • 21
  • Please do **not** edit your question to include an answer. Either mark an answer as accepted, if one suits you, or post your own answer that you can accept – Camilo Terevinto May 30 '18 at 21:46
  • Sorry... but I didn't want to post my own answer over the top of other people's answers. Anyway... it didn't work, so I'd already removed it. – Ade May 30 '18 at 22:29

3 Answers3

2

The result that you get is correct. You request all navItems as a list, and you get a list with all navItems. The fixup of the child nodes is a nice 'bonus' that EF did for you.

You also correctly apply the filter on the instantiated list instead of the dataset directly. Were you to apply it on the dataset, your child nodes would not have been polulated! Then, to populate the child nodes you would have to use Includes, but that would only get you 1 level, which might not be enough.. of course you could add multiple includes but this would not scale well...

Your "bodgy" way of doing it seems not so bodgy after all.. I find it rather elegant.

Floris
  • 118
  • 1
  • 10
  • Actually, the two answers above are spot on: If you include a "parent" property, then add .Where(p => p.parentId == null), it does indeed work & produces the correct parent/child collection. I was just being dumb & not looking at the correct parent item! – Ade May 30 '18 at 21:17
  • No, wait, I take that back. I just ran it again and it didn't load the children.... – Ade May 30 '18 at 21:33
  • I agree with the other 2 answers that a explicit key is easier in usage and filtering. Just to make sure: You did not select any items in the same context before you tried the query with parent-key == null ? This would cause automatic fix-up for the newly requested query. As far as I know and tried a few times, EF core does not do automatic loading of relations if you don't explicitly say you want them. I am very interested in the resulting SQL from your linq query – Floris May 30 '18 at 21:42
  • Yep, once you add the filtering on root you'll need a `.Include` on the children to load the hierarchy but it should load the entire hierarchy once used, see: https://stackoverflow.com/a/41837737/84206 – AaronLS May 30 '18 at 21:44
  • I'm not sure how to get the underlying SQL it's executing (I'm using sqlite if that's of any help). I can throw the code into a barebones project & share it if you like.. give me 15 mins... – Ade May 30 '18 at 21:44
  • Personally i find the list + manual filter very nice (it should result in 1 query to the database)... but if you dont mind 2 queries, then you can use: var children = db.navItems.Where(x=>x.parent != null).ToList(); var root = db.navItems.Where(x=>x.parent == null).ToList(); root will now have a proper list of root items and populated children.. you can ignore the children variable, it was only used to make the context aware of these records for automatic fixup – Floris May 30 '18 at 21:47
  • @AaronLS - Using .Include only loads 1 level of the heirachy (just tried it). – Ade May 30 '18 at 21:51
  • @AaronLS Interesting link, but i think the resulting list that they get is still a list with all items (root + children) that are simply fixed up.. so that is the same as the OP's original query. – Floris May 30 '18 at 21:52
  • @Ade the sql query is only interesting if the suggested root filter as a stand alone solution would actually work...since that would have made me question my understanding of EF core :) – Floris May 30 '18 at 21:56
  • Reviewing the question once more, my focus was in the wrong direction (sql roundtrips). I missed the point of the implicit key + extra member vs explicit key. Camilo's answer is partially correct in that you have to apply the FK filter on the instantiated list and not on the dataset directly (this will not load the children) – Floris May 30 '18 at 22:26
  • @Floris - It does seem that it can't be done in one operation. I've uploaded a barebones version of my code, you can grab it from here: https://github.com/adev73/EFCore-LoadParentWithChildren-Example – Ade May 30 '18 at 22:28
  • EF automatically links objects that are cached in the db context. Since you are reusing the context for each test, these tests are flawed. You should create a new context for each separate test to avoid interference. The last test in a new context looks like it should work... – Floris May 30 '18 at 22:44
  • @Floris - Changed it, same results. – Ade May 30 '18 at 22:56
1

Navigation properties always have backing foreign keys. You can declare the foreign keys explicitly and leverage them in queries. If you add the parent foreign key to the model class then you can filer on ParentKey == null to get only root items. You're not adding a new column, as this column already exists in some form, your just including it explicitly in your model so it can be used in queries. What you parent key is named and what it will be named when you add it to the model depends on your mapping. There's plenty of questions on mapping these types of keys.

Here is a similar example, notice ParentId declared in the model: Ordering a self-referencing hierarchy in LINQ

I always include all foreign keys in the model because it becomes handy for certain types of queries or update operations where setting the FK is more efficient than querying the related object to create a relationship. It also ensures your FK columns aren't strangely named by EF.

AaronLS
  • 37,329
  • 20
  • 143
  • 202
  • If I filter it on FK == null, then none of the children are loaded. (I'm assuming you mean something like `var nI = db.navItems.Where(p => p.fk == null).ToList()`? – Ade May 30 '18 at 21:06
1

I might be wrong, but, if you have children, don't you need a parent?

public class NavItem
{
    [Key]
    [DatabaseGenerated(DatabaseGeneratedOption.Identity)]
    public int navItemId { get; set; }
    public int? parentNavItemId { get; set; }
    public int sortOrder { get; set; }
    public bool isTitle { get; set; }
    public bool isDivider { get; set; }
    public string cssClass { get; set; }
    public string name { get; set; }
    public string url { get; set; }
    public string icon { get; set; }
    public string variant { get; set; }
    public string badgeText { get; set; }
    public string badgeVariant { get; set; }

    public NavItem parentNavItem { get; set; }
    public virtual ICollection<NavItem> children { get; set; }
}

Then you would filter by those without a parent:

var items = db.NavItems
    .ToList() // get every item so that relations load correctly
    .Where(x => x.parentNavItemId == null) // then filter by top-level
    .ToList();
Camilo Terevinto
  • 31,141
  • 6
  • 88
  • 120
  • You need a parent in the database, but EF does that for you (the field name's a bit wonky, but that's not important right now). However, for classes containing collections of other classes (or even of the same class, i.e. recursive), no - you don't need parents... unless you need to navigate UP the tree from a start point that isn't root - not something I need to do. – Ade May 30 '18 at 21:19
  • Regarding c# naming conventions.... I presume you're largely refering to my use of camelCase instead of PascalCase? Sorry... I prefer camelCase for properties, and PascalCase for types/classes. In fact, I prefer it so much, I tweaked the rules in Visual Studio 2017 to stop pestering me to change to PascalCase... – Ade May 30 '18 at 21:21
  • @Ade EF does that for you, but you cannot filter on that and it's *always* better to be explicit. I don't see another way of saying "this is the root" on your structure, and that seems to be your problem – Camilo Terevinto May 30 '18 at 21:38
  • It appears that either the FK must be made available (as you describe), or an additional "isRoot" or similar property must be made available. The visible FK is better, because it's pretty much guaranteed to be accurate. – Ade May 30 '18 at 21:41
  • @Ade Then I don't see the problem with my answer...? I could run this on an in-memory database and see whether it loads the items correctly. But an `isRoot` column would be weird – Camilo Terevinto May 30 '18 at 21:43
  • This bit: `var items = db.NavItems.Where(x => x.parentNavItemId == null).ToList();` doesn't work. You have to query first, filter later (unless someone finds a way to do it). See example project here: https://github.com/adev73/EFCore-LoadParentWithChildren-Example – Ade May 30 '18 at 22:27
  • @Ade That seems to be the case. I have edited the answer to reflect that – Camilo Terevinto May 30 '18 at 22:39