Post

Smarter Subgraphs - Kusto as a Engine For Power BI

Move beyond VertiPaq's limitations. Using Kusto Database with Direct Query and motif matching to create dynamic, clean, and performant graph visual in Power BI

Smarter Subgraphs - Kusto as a Engine For Power BI

In a couple of previous posts, I detailed how to get permission data into a graph structure with Graphframes and visualize it in Power BI using a force-directed Deneb visual. This approach is great for understanding permission inheritance from the perspective of Fabric Items, but it has a significant drawback when you try to view the graph in reverse, from the User’s perspective.

This post introduces a better way. By swapping the VertiPaq engine for a Fabric Kusto database, we can leverage its native graph processing capabilities to solve the subgraph filtering problem.

The Problem: Why VertiPaq Falls Short

My original approach has a limitation! Filtering to sub-graph relies on pre-baked grouping of paths, represented by a field with item-permission grouping. This works perfectly when you filter by a permission or item.

But what if you want to filter by a user and see all the items they can access? Because the VertiPaq engine lacks native recursion, it can’t traverse the graph “backwards” or discover multi-step paths on the fly. The result is a cluttered visual, polluted with irrelevant user groups that don’t directly answer the user’s question. You can see below, if I filter to a specific user, in the old solution (top), it is littered with irrelevant groups and users, what we want in the bottom visual, providing a clean and clear answer.

Problem

You could try to solve this by calculating even more pre-baked paths, but that just adds complexity and bloats the model. But there has to be a better way… maybe another engine… in Fabric… with built in graph processing… with motif-matching…. Kusto Database?!

The Solution: The Right Engine for the Job

Instead of importing data into VertiPaq, we can use a Kusto Database in Direct Query mode. This approach lets us leverage a engine designed for graph processing.

By connecting Power BI to Kusto, we can use dynamic M parameters to pass user selections from our report directly into a KQL query. This query filters the graph on the server side and sends only the small, relevant subgraph back to Power BI for visualization.

Kusto’s Graph Semantics in Action

Kusto is the blazing-fast analytics engine behind Azure Data Explore and Fabric’s Real-Time Intelligence. Crucially for us, it offers built-in graph semantics, including operators for creating and matching patterns within a graph.

First, I loaded the same triplet-form permission data from my previous post into a Kusto database (see below).

accessToItemGroupIdsrcIdsrcTypesrcNamedstIddstTypedstName
ATG-WKS-5b6962ef-ContributorWKS-5b6962efWorkspaceUSS-OdysseyUG-D5CE28GroupMedical Officer
ATG-WKS-5b6962ef-ContributorWKS-5b6962efWorkspaceUSS-OdysseyUG-2C73C1GroupCommander
ATG-WKS-5b6962ef-AdminWKS-5b6962efWorkspaceUSS-OdysseyUG-5A5753GroupSecurity Officer
ATG-WKS-5b6962ef-ViewerWKS-5b6962efWorkspaceUSS-OdysseySF-4E1026UserAaron Gonzalez
ATG-WKS-5b6962ef-ViewerWKS-5b6962efWorkspaceUSS-OdysseySF-6FD6FFUserKylie Coleman
ATG-WKS-5b6962ef-MemberWKS-5b6962efWorkspaceUSS-OdysseySF-6D835AUserTyler Miles
ATG-WKS-d6a39ae5-ViewerWKS-d6a39ae5WorkspaceStarbase-IntrepidUG-756300GroupEngineering Officer
ATG-WKS-d6a39ae5-AdminWKS-d6a39ae5WorkspaceStarbase-IntrepidSF-500B65UserRobin Jones
ATG-WKS-a8083ba1-ContributorWKS-a8083ba1WorkspaceUSS-OdysseyUG-5A5753GroupSecurity Officer
ATG-WKS-a8083ba1-ViewerWKS-a8083ba1WorkspaceUSS-OdysseySF-FEE711UserAmanda Reynolds

Then, I crafted a single KQL query to do all the work.

The core of this query is the make-graph operator, which builds a graph in memory, and the graph-match operator, which performs motif matching. Motif matching is a powerful technique for finding specific structural patterns. For example, finding all paths that start at a specific item, pass through a user group, and end at a particular user.

My KQL query is designed to find all paths that satisfy these criteria:

  • Starts with a node from a user-selected list (_StartingNodes)
  • Ends with a node from another user-selected list (_EndingNodes)
  • Follows a path between 1 and 5 steps long
  • Obeys the direction of the relationship (e.g., Item -> Group -> User)
// Parameters passed via dynamic M parameter
let _StartingNodes = dynamic(['Excelsior ZetaExcelsior 1']);
let _EndingNodes = dynamic(['Vincent Bates"']);
let _Permissions = dynamic(['Read', 'ReadWrite']);
// Triplet to Vertices and Edges
let data = permissions | extend permission = tostring(split(accessToItemGroupId, '-')[-1]);
let nodes = 
    union
        (data | distinct srcId, srcType, srcName | project Id = srcId, Type = srcType, Name = srcName),
        (data | distinct dstId, dstType, dstName | project Id = dstId, Type = dstType, Name = dstName)
    | distinct Id, Type, Name;
// Filter edges by Permission    
let edges = data | distinct source = srcId, destination = dstId, permission
| where isempty(_Permissions) or permission in (_Permissions);
// Generate Graph
edges
| make-graph source --> destination with nodes on Id
// Motif-matching and filtering by Starting and Ending Node
| graph-match (start_node)-[path_edge*1..5]->(end_node)
where
    (isempty(_StartingNodes) or start_node.Name in (_StartingNodes))
    and (isempty(_EndingNodes) or end_node.Name in (_EndingNodes))
project
    srcId = start_node.Id,
    srcName = start_node.Name,
    srcType = start_node.Type,
    dstId = end_node.Id,
    dstName = end_node.Name,
    dstType = end_node.Type,
    Path = path_edge
// Unpack nested structure to individual rows
| mv-expand Path
// Edges to triplet
| project
    srcId = tostring(Path.source),
    dstId = tostring(Path.destination),
    permission = tostring(Path.permission)
| distinct 
    srcId,
    dstId,
    permission
| join kind=inner nodes on
    $left.srcId == $right.Id
| project 
    srcId, 
    srcType = Type, 
    srcName = Name, 
    dstId,
    permission
| join kind=inner nodes on
    $left.dstId == $right.Id
| project 
    srcId,
    srcName,
    srcType, 
    dstId, 
    dstType = Type, 
    dstName = Name,
    permission

This query takes the user’s input and returns a perfectly trimmed table containing only the relevant edges for the visualization, in the triplet form the Deneb visual expects.

srcIdsrcNamesrcTypedstIddstTypedstNamepermission
REP-d3dbd2d3Excelsior ZetaExcelsior 1ReportUG-2C73C1GroupCommanderReadWrite
REP-d3dbd2d3Excelsior ZetaExcelsior 1ReportSF-D1D5EFUserVincent BatesRead
UG-2C73C1CommanderGroupSF-D1D5EFUserVincent BatesRead
UG-2C73C1CommanderGroupSF-D1D5EFUserVincent BatesReadWrite

Wiring It Up in Power BI

With the KQL query ready, the setup in Power BI is straightforward. I only required minimal changes to the Semantic Model to make this all work.

Data Model

Here’s the process:

  • Create M Parameters: In Power Query, create three text parameters: _StartingNodes, _EndingNodes, and _Permissions
  • Create Parameter Tables: Create three separate tables (DP_items, DP_Permissions, DP_Users). These will populate our slicers. I found that I needed to create separate table for the dynamic M parameter, otherwise I got some errors.
  • Set Up the Direct Query Source: Create a new query using the AzureDataExplorer.Contents connector, using the KQL script from above.
  • Bind Parameters: In the Power BI data model view, select each disconnected table and bind its data column to the corresponding M parameter you created in step 1

To setup the power query code for dynamic M parameter I followed the helpful guide from Chris Webb on arbitrary dynamic M parameters and the MS Docs on the feature. Of note, some processing is required to convert lists from Power BI to strings that can be incorporated into the query.

let
    StartingNodes = 
        if Type.Is(Value.Type(_StartingNodes), List.Type)
        then Text.Combine({"'", Text.Combine(_StartingNodes, "','"), "'"})
        else Text.Combine({"'", _StartingNodes, "'"}),
    endingNodes = 
        if Type.Is(Value.Type(_EndingNodes), List.Type)
        then Text.Combine({"'", Text.Combine(_EndingNodes, "','"), "'"})
        else Text.Combine({"'", _EndingNodes, "'"}),
    permissions =
        if Type.Is(Value.Type(_Permissions), List.Type)
        then Text.Combine({"'", Text.Combine(_Permissions, "','"), "'"})
        else Text.Combine({"'", _Permissions, "'"}),
    Source = AzureDataExplorer.Contents(
        "https://trd-y4vwg29chydm6jmuga.z0.kusto.fabric.microsoft.com", 
        "Graph", 
        "let _StartingNodes = dynamic([" & StartingNodes & "]); //dynamic(['Excelsior ZetaExcelsior 1']);
        let _EndingNodes = dynamic([" & endingNodes & "]); //dynamic(['Vincent Bates']);
        let _Permissions = dynamic([" & permissions & "]); //dynamic(['Read', 'ReadWrite']);
        let data = permissions | extend permission = tostring(split(accessToItemGroupId, '-')[-1]);
        let nodes = 
            union
                (data | distinct srcId, srcType, srcName | project Id = srcId, Type = srcType, Name = srcName),
                (data | distinct dstId, dstType, dstName | project Id = dstId, Type = dstType, Name = dstName)
            | distinct Id, Type, Name;
        let edges = data | distinct source = srcId, destination = dstId, permission
        | where isempty(_Permissions) or permission in (_Permissions);
        edges
        | make-graph source --> destination with nodes on Id
        | graph-match (start_node)-[path_edge*1..5]->(end_node)
        where
            (isempty(_StartingNodes) or start_node.Name in (_StartingNodes))
            and (isempty(_EndingNodes) or end_node.Name in (_EndingNodes))
        project
            srcId = start_node.Id,
            srcName = start_node.Name,
            srcType = start_node.Type,
            dstId = end_node.Id,
            dstName = end_node.Name,
            dstType = end_node.Type,
            Path = path_edge
        | mv-expand Path
        | project
            srcId = tostring(Path.source),
            dstId = tostring(Path.destination),
            permission = tostring(Path.permission)
        | distinct 
            srcId,
            dstId,
            permission
        | join kind=inner nodes on
            $left.srcId == $right.Id
        | project 
            srcId, 
            srcType = Type, 
            srcName = Name, 
            dstId,
            permission
        | join kind=inner nodes on
            $left.dstId == $right.Id
        | project 
            srcId,
            srcName,
            srcType, 
            dstId, 
            dstType = Type, 
            dstName = Name,
            permission 
        ", 
        [MaxRows=null, MaxSize=null, NoTruncate=null, AdditionalSetStatements=null]
    )
in
    Source

The parameters can easily to bound to fields in model view.

Bind Parameters

That’s it! No changes are needed to the Deneb visual specification. We just swap the fields from the old import-mode table with the fields from our new Permission Triplets (DQ) table. The result is a clean, focused graph showing only the nodes and edges that matter.

Solution

Conclusion

By offloading the graph traversal logic to Kusto, we achieve three key benefits:

  • Performance: Kusto is purpose-built for this kind of query and returns results incredibly quickly
  • Scalability: The Power BI model remains lightweight and responsive, as the full graph dataset is never imported
  • Clarity: Users get immediate, uncluttered answers to their questions, free from the visual noise of the old approach

While this solution is powerful, the graph is transient, and is rebuilt in memory for each query. In the future, it would be interesting to explore a persistent graph solution using a dedicated graph database, like Cosmos DB, which was recently announced to be coming to Fabric. But for now, using Kusto as a graph engine is a massive leap forward for interactive graph analysis in Power BI.

This post is licensed under CC BY 4.0 by the author.