Best Practice way for querying a content only page type and all it's nested typed nodes

Stuart1 Freeman1 asked on January 13, 2020 02:01

Hi All,

I am new to Kentico and I keep running into the same issue and I have yet to get a clear idea or answer on what is the best practice way to do what seems to me would be a very common task in a Kentico MVC site.

I have content only page types that when added to the Pages app in the admin have multiple child nodes of a different type which in turn may have child nodes of a different type.

Example:

GenericPageWithAccordion (Type)
    - (child node) Accordion (Type)
        - (child node of Accordion) AccordionItem (Type)

In my controller I'd like to pass the NodeAliasPath of the root node GenericPageWithAccordion to my GenericPageWithAccordion Repository and return a strongly typed GenericPageWithAccordion item with it's child Accordion child items and their AccordionItems child items.

I'm just not quite getting how to do this while being able to keep a query like this cacheable. If I use MultiDocumentQuery this should return a single root document with all the children but how do I cast/convert this from MultiDocumentQuery to my GenericPageWithAccordion type with all the strongly typed children, grandchildren etc items?

I see pieces of this solution out there but there doesn't seem to be a definitive example that lends to the best practices being communicated in the Dancing Goat example or any other official documentation.

I can't really imagine a CMS driven website out there that wouldn't be full of Pages that WEREN'T structured like this.. Where the root Page type is made up of a tree of other typed information.

Correct Answer

Sean Wright answered on January 14, 2020 16:18

@stuart,

I typically use multiple queries, mostly due to the stronger typing of the resulting C# and less complex queries that approach gives.

To avoid performance hits, I tend to use Kentico's Caching APIs heavily.

https://docs.kentico.com/k12sp/configuring-kentico/configuring-caching/caching-in-custom-code

I also cover caching in my blog series:

https://dev.to/seangwright/kentico-12-design-patterns-part-12-database-query-caching-patterns-43hc

https://dev.to/wiredviews/kentico-cms-quick-tip-fluentcachekeys-consistent-cache-dependency-key-generation-9ek

You might be able to get what you're looking for through other APIs, but it's up to you to decide if the complexity/readability cost outweighs the performance cost.

You are right that putting those types of queries in the View Model or Controller is messy. I typically don't recommend tying data access to the view layer of an application.

Here's some more querying/caching related posts:

https://dev.to/seangwright/kentico-12-design-patterns-part-17-centralized-cache-management-through-decoration-2eo5

https://dev.to/seangwright/kentico-12-design-patterns-part-14-documentquery-and-objectquery-tips-2c7h

1 votesVote for this answer Unmark Correct answer

Recent Answers


Roman Hutnyk answered on January 13, 2020 12:03

To get pages of different types your query should look like this:

var documents = DocumentHelper.GetDocuments()
   .Type("custom.pagetype", q => q.Columns
("DocumentName", "FirstName", "LastName", "Degree", "DocumentNodeID")
    .Path("/My-Url", PathTypeEnum.Children)
    .OrderBy("LastName,FirstName"))
  .OnCurrentSite();

I believe you'll have to cast the result to the strongly typed objects on your own.

Also you'll need to add some code in order to cache the results. Here you could find more details.

0 votesVote for this answer Mark as a Correct answer

Stuart1 Freeman1 answered on January 13, 2020 15:06 (last edited on January 13, 2020 15:12)

Hi Roman,

I understand this but what I don't understand is how I can then take the nested results and cast them to their appropriate models. If I have multiple types:

var docs = DocumentHelper.GetDocuments()
            .Type("MyCustom.ContentWithAccordions", q => q
                .Columns("MetaTitle", "MetaDescription", "Heading", "Image", "QuickLinkIcon",
                "Subheading", "Body", "CalloutHeading", "CalloutText", "CalloutLink", "CalloutIcon",
                "CalloutBackgroundImage", "CalloutBackgroundColour", "PromotionBanner"))
            .Type("MyCustom.AccordionHeader", q => q
                .Columns("PageName", "NodeAliasPath", "Header"))
            .Type("MyCustom.AccordionItem", q => q
                .Columns("PageName", "NodeAliasPath", "Heading", "Body"))
            .WhereEquals("NodeAliasPath", nodeAliasPath)
            .PublishedVersion()
            .Published()
            .OrderBy("NodeLevel", "NodeOrder", "NodeName");

This returns a multidocumentquery. There will only be one doc at the top level as I am filtering by nodeAliasPath, so how can I extract and cast the parent doc/treenode and then traverse the tree casting each treenode to it's type accordingly? The answer may be super obvious it's just not to me due to lack of experience. How can I pluck the parent/top node out of this MultiDocumentQuery?

Additionally, is this the way you would find yourself grabbing all the required data for a Page like this? The brute force way would be to query for the parent, query for the accordionheaders, loop through the accordionheaders and query for the associated accordionitems. Each query could be strongly typed and would solve my problem but would result in a large number of queries for a single page.

0 votesVote for this answer Mark as a Correct answer

Sean Wright answered on January 14, 2020 04:46 (last edited on January 14, 2020 04:54)

@Stuart,

You would need to change your query to have more than (1) result in your docs variable.

.Path(nodeAliasPath, PathTypeEnum.Section) will give you the "document and its child documents" and .Path(nodeAliasPath, PathTypeEnum.Children) will give you "all child documents".

Replace your .WhereEquals("NodeAliasPath", nodeAliasPath) with one of those.

When you get your results back you can iterate over them and use the .ClassName property of the TreeNode to find out what to cast it to.

foreach(TreeNode node in documents)
{
    if (string.Equals(AccordionHeader.CLASS_NAME, node.ClassName, StringComparison.OrdinalIgnoreCase))
    {
        AccordionHeader header = node as AccordionHeader;
    }
    
    // This might also work
    if (node is AccordionHeader headerNode)
    {
        var header = headerNode.Header;
    }
}

If you are interested in what SQL is being generated by your DocumentQuery<T> code, you can use the .GetFullQueryText(); method to return the hydrated query as a string, which you can copy into SSMS or Azure Data Studio to execute.

var query = DocumentHelper.GetDocuments()
    .Type("MyCustom.AccordionHeader", q => q
        .Columns("PageName", "NodeAliasPath", "Header"))
    ...
    .WhereEquals("NodeAliasPath", nodeAliasPath)
    .PublishedVersion()
    .Published()
    .OrderBy("NodeLevel", "NodeOrder", "NodeName");
    
string queryText = query.GetFullQueryText();

var documents = query.ToList();

If you find yourself wanting to look at / debug queries often in your code, you could make an extension method like:

public static class MultiDocumentQueryExtensions
{
    /// <summary>
    /// Prints the SQL that will be generated by the provided <see cref="MultiDocumentQuery" /> to <see cref="Debug"/>. Includes SQL variables and values.
    /// </summary>
    /// <param name="query"></param>
    /// <returns></returns>
    public static MultiDocumentQuery PrintToDebug(this MultiDocumentQuery query)
    {
        string queryText = query.GetFullQueryText();

        Trace.WriteLine("\r\n");
        Trace.WriteLine("--- Query Start ---");
        Trace.WriteLine("\r\n");

        Trace.WriteLine(queryText);

        Trace.WriteLine("\r\n");
        Trace.WriteLine("--- Query End ---");
        Trace.WriteLine("\r\n");

        return query;
    }
}

Which could be used as follows:

var documents = ...
    .WhereEquals("NodeAliasPath", nodeAliasPath)
    .PublishedVersion()
    .Published()
    .OrderBy("NodeLevel", "NodeOrder", "NodeName")
    .PrintToDebug()
    .ToList();

If you are debugging when the query gets executed, you will see the full text in Visual Studio's Output window.

1 votesVote for this answer Mark as a Correct answer

Keio Kwan answered on January 14, 2020 09:12

Hi there, what I can see you are only querying nodes in flat list, not nesting. and Kentico is not support nesting as object in unless you cast it to.

I would suggest you to use code tool provided at page type for generating the class and add your own nested children nodes with type(s). Actually the TreeNode object type is already provided the .Children property to get children.

1 votesVote for this answer Mark as a Correct answer

Stuart1 Freeman1 answered on January 14, 2020 15:38

Hi all,

@Sean Wright - thanks for this useful information. Ultimately what I am looking for is a way to avoid making multiple DB queries per 'Page'. Where I could make a single query that would return the entire Page node and all it's constituent parts in the tree. In my example I have the Page level node and then a number of Accordion Header nodes and then Accordion Item nodes under each Accordion Header node. I don't even know if this is the "right" way to go about this. If I have caching properly setup then looping through the Accordion Header nodes and making a separate query for each of the Header nodes Accordion Items might not be a problem. This is what I am doing now. Looping through each of the Accordion headers and making a strongly typed query for each header's Accordion Items.

I would love to understand how other developers are handling this same type of situation? Single query that grabs everything or Multiple Queries in a loop?

I am doing a single query in the case of a nested menu I have. And then using recursion to navigate the tree and build my menu. But in this case I'm unsure how to separate the parsing of this in a clean way. Put it in the repository which then is returning a viewmodel or put it in the controller (seems a bit messy).

@Keio Kwan - It seems to be returning a nested tree result in another query I am doing:

return DocumentHelper.GetDocuments()
            .Type("MyCustom.Menu", q => q
                .Columns("TopMenuItemsID", "MenuName"))
            .Type("MyCustom.MenuItem", q => q
                .Columns("MenuItemID", "MenuItemText", "MenuItemPage", "MenuItemIcon", "ExternalUrl"))
            .OnSite(SiteContext.CurrentSiteName)
            .Path("/My-menu")
            .OrderBy("NodeLevel", "NodeOrder", "NodeName")
            .Published();

I'm guessing that the .Type casting is allowing for a nested tree result? Is that what you mean?

Thanks all for your help! I am still interested in hearing in more detail how you are all handling this kind of scenario. Where you have a Page Type with multi level nested content of differing types. How are you building up the result to send to your view? Multiple queries?

0 votesVote for this answer Mark as a Correct answer

Stuart1 Freeman1 answered on January 14, 2020 23:30

Thanks @Sean, an opinion is exactly what I was looking for. Just getting going with Kentico I realize there are many ways to most tasks related to delivering an MVC website. Some best practices are helpful in getting productive.

0 votesVote for this answer Mark as a Correct answer

Sean Wright answered on January 14, 2020 23:48

@Stuart,

Glad to hear you found my recommendations helpful. I'm continuing to update my blog series - so you can look there to find additional tips and ideas in the future.

Kentico also provides links to all the MVP's blog posts https://www.kentico.com/company/mvp which contain lots of helpful information.

Finally, I'd recommend joining the Kentico Slack community at https://kentico-community.slack.com/ where you can chat with us and discuss with others what you're trying to do with Kentico.

1 votesVote for this answer Mark as a Correct answer

Stuart1 Freeman1 answered on January 15, 2020 15:24

Thanks Sean, how can I join the slack channel? I have a slack account for some other spaces but can't seem to get into the kentico slack. Thanks!

0 votesVote for this answer Mark as a Correct answer

Sean Wright answered on January 15, 2020 18:16

@Stuart,

Email me at sean at wiredviews.com and I'll send ya an invite.

0 votesVote for this answer Mark as a Correct answer

   Please, sign in to be able to submit a new answer.