Click here to Skip to main content
65,938 articles
CodeProject is changing. Read more.
Articles
(untagged)

Advanced WPF TreeViews in C#/VB.Net Part 4 of n

0.00/5 (No votes)
22 Dec 2017 1  
Tips & Tricks on loading and saving WPF Tree View based content.

Introduction

We are investigating advanced WPF tree view implementations. The 1st and 2nd part of this series compared virtualized and non-virtualized implementations, and the third was focused on traversing tree structures.

A general understanding of tree traversal was required to implement the efficient tree view filter application in the 3rd [3] article. But this is also required to save and load data from and to a WPF tree view. And while the last article [3] briefly touched the subject of saving and loading data, I felt some need to detail this with an article of its own. this article also covers storing and retrieving tree view based data in a relational database system (here SQLite), as it is not trivial, but often required, and not well documented elsewhere.

The article is structured around 2 demo applications, where the first SQLite Demo application is focused on storing tree structured data in a relational database backend system. This part of the article should also be interesting for anyone wishing to store and retrieve tree structured data from non-WPF technologies, such as, Winform based TreeViews, Java Tree Structures etc.) in a relational database system.

The second part of the article combines the basics learned from the SQLite Demo and revisits the WPF 00_InplaceEditBoxLib.zip demo from the first article to integrate a save/load backend data system.

SQLite Model Demo

A solution to the task of storing and retrieving tree based data in a relational database system is not trivial, because relational data is constrained to tables, while tree structures can come in manner different forms and shapes. A solution to this problem is the Adjacency List Model [5], which requires us to store tree nodes in dependence of their level within the tree. This solution is not difficult (anymore) since we can use the Level-Order traversal [3] algorithm to visit and store nodes with respect to their level.

Then when we have stored tree nodes with their level - we can retrieve them ordered by their level - rebuilding the tree from its root to its leaves. Retrieving nodes from a database table requires us to store their:

  1. Level (0, 1, 2, 3, ... n) and
  2. A link to the parent node (id to parent of current node)

So, 1. is required to rebuild the tree in order from root to leaf, while 2. is required to insert each item retrieved from the database in its correct spot. Lets explore that idea with the SQLite_Demo attached to this article. This demo implements 2 projects:

  • SolutionModelsLib (DLL project)
  • SQLite_Demo (MS-DOS Console project)

The SolutionModelsLib implements a simplified tree structured model using 3 classes:

where the SolutionModel represents the container of the tree model, while the SolutionRootItemModel is the first visible item in the collection, and the SolutionItemModel implements all other items that can be visible in the tree structure (File, Folder, Project).

The second part of the SolutionModelsLib project contains the SQLIteDatabase class [4] that wraps the SQLite database system, and the SolutionDB class that implements the specific storage and retrieval methods for this demo.

Storing Tree Structured Data in a Relational Database System

The Write database model region in the SolutionDB class contains a ReCreateDBTables method that creates the empty tables: itemtype and solution. The itemtype table is used to store the enumeration from the SolutionItemType enum, while the solution table contains the adjacency list (the data of the tree) of the solution.

CREATE TABLE IF NOT EXISTS
  [itemtype] (
  [id]           INTEGER      NOT NULL PRIMARY KEY,
  [name]         VARCHAR(256) NOT NULL
)

CREATE TABLE IF NOT EXISTS
  [solution] (
  [id]           INTEGER      NOT NULL PRIMARY KEY,
  [parent]       INTEGER      NOT NULL,
  [level]        INTEGER      NOT NULL,
  [name]         VARCHAR(256) NOT NULL,
  [itemtypeid]   INTEGER      NOT NULL,
  FOREIGN KEY (itemtypeid) REFERENCES itemtype(id)
)

The InsertItemTypeEnumeration method writes the itemtype enumeration values into its table and the InsertSolutionData method persists the tree structured data:

private int WriteToFile(SolutionModel solutionRoot
                      , SQLiteCommand cmd)
{
  int result = 0;
  int iKey = 0;
  Queue<Tuple<int, SolutionItemModel>> queue = new Queue<Tuple<int, SolutionItemModel>>();

  if (solutionRoot.Root != null)
      queue.Enqueue(new Tuple<int, SolutionItemModel>(0, solutionRoot.Root));

  while (queue.Count() > 0)
  {
    var queueItem = queue.Dequeue();
    int iLevel = queueItem.Item1;
    SolutionItemModel current = queueItem.Item2;

    current.Id = iKey++;
    int parentId = (current.Parent == null ? -1 : current.Parent.Id);

    cmd.Parameters.AddWithValue("@id", current.Id);
    cmd.Parameters.AddWithValue("@parent", parentId);
    cmd.Parameters.AddWithValue("@level", iLevel);
    cmd.Parameters.AddWithValue("@name", current.DisplayName);
    cmd.Parameters.AddWithValue("@itemtypeid", (int)(current.ItemType));
    
    result += cmd.ExecuteNonQuery();

    foreach (var item in current.Children)
      queue.Enqueue(new Tuple<int, SolutionItemModel>(iLevel + 1, item));
  }

  return result;
}
Private Function WriteToFile(ByVal solutionRoot As SolutionModel, ByVal cmd As SQLiteCommand) As Integer
  Dim result As Integer = 0
  Dim iKey As Integer = 0
  Dim queue As Queue(Of Tuple(Of Integer, SolutionItemModel)) = New Queue(Of Tuple(Of Integer, SolutionItemModel))()

  If solutionRoot.Root IsNot Nothing Then queue.Enqueue(New Tuple(Of Integer, SolutionItemModel)(0, solutionRoot.Root))

  While queue.Count() > 0
    Dim queueItem = queue.Dequeue()
    Dim iLevel As Integer = queueItem.Item1
    Dim current As SolutionItemModel = queueItem.Item2

    current.Id = iKey
    iKey = iKey + 1
    Dim parentId As Integer = (If(current.Parent Is Nothing, -1, current.Parent.Id))

    If cmd IsNot Nothing Then
      cmd.Parameters.AddWithValue("@id", current.Id)
      cmd.Parameters.AddWithValue("@parent", parentId)
      cmd.Parameters.AddWithValue("@level", iLevel)
      cmd.Parameters.AddWithValue("@name", current.DisplayName)
      cmd.Parameters.AddWithValue("@itemtypeid", CInt((current.ItemType)))
      result += cmd.ExecuteNonQuery()
    Else
      Console.WriteLine(String.Format("{0,4} - {1} ({2})", iLevel, current.GetStackPath(), current.ItemType.ToString()))
    End If

    For Each item In current.Children
      queue.Enqueue(New Tuple(Of Integer, SolutionItemModel)(iLevel + 1, item))
    Next
  End While

  Return result
End Function

We recognize this code pattern from the previous article and know that the code implements the Level-Order traversal algorithm, -and only the bold marked parts operate on each traversed tree node (storing tree node data in the SQLite table is the operation here).

We can see that the iKey table Id is computed on the fly and the iLevel information falls practically out of the algorithm.

So, this is, how we can store tree structured data in a relational table structure. Let's now switch gears and see how loading this data can work.

Retrieving Tree Structured Data from a Relational Database System

Retrieving the data stored in the previous section to re-build the tree on load is surprisingly easy since we've done all the heavy lifting when writing the data. All we really need is a select statement to get the tree data from the solution table:

SELECT * FROM solution ORDER BY level, id

The important part here is the ORDER BY level - this makes sure that we retrieve the tree nodes from top to bottom as we have written them. The Order BY id part is not really required but I like to have things in order when I can.

So, the above statement gives us an id of each tree view node, the id of it's parent, the level of the node, and some application dependent data like name and type of the node. Now, we can retrieve these nodes from top to bottom and we can insert each node in its correct spot (underneath its parent) if we keep a log of the previously retrieved nodes and their ids. This idea is implemented in the ReadSolutionData method of the SolutionDB class.

And while the code in the ReadSolutionData method may seem long its actually less complicated than any of the traversal methods we have seen previously. The first part of the algorithm inserts the root item if the query has any results at all - we can do it like this since the root is expected to be first (it was written first) and this tree view implementation allows only one root item (so all other items are non-root items by definition).

public int ReadSolutionData(SolutionModel solutionRoot
                          , SQLiteDatabase db = null)
{
  int recordCount = 0;

  var query = "SELECT * FROM solution ORDER BY level, id";
  using (SQLiteCommand cmd = new SQLiteCommand(query, db.Connection))
  {
      using (SQLiteDataReader selectResult = cmd.ExecuteReader())
      {
          Dictionary<int, SolutionItemModel> mapKeyToItem = new Dictionary<int, SolutionItemModel>();

          if (selectResult.HasRows == true)
          {
              if (selectResult.Read() == true)
              {
                  var root = solutionRoot.AddSolutionRootItem(selectResult["name"].ToString());

                  mapKeyToItem.Add(selectResult.GetInt32(0), root);
                  recordCount++;
              }

              while (selectResult.Read() == true)
              {
                  int iParentKey = selectResult.GetInt32(1); // Get parent key from next item
                  SolutionItemModel parent = null;

                  if (mapKeyToItem.TryGetValue(iParentKey, out parent) == true)
                  {
                      var itemTypeId = (long)selectResult["itemtypeid"];

                      var item = parent.AddChild(parent
                                              , selectResult["name"].ToString()
                                              , itemTypeId);

                      mapKeyToItem.Add(selectResult.GetInt32(0), item);
                      recordCount++;
                  }
                  else
                  {
                      throw new Exception("Data written is corrupted.");
                  }
              }
          }
      }
  }

  return recordCount;
} 
Public Function ReadSolutionData(ByVal solutionRoot As SolutionModel, ByVal Optional db As SQLiteDatabase = Nothing) As Integer

  If db Is Nothing Then db = Me

  Dim recordCount As Integer = 0
  Dim query = "SELECT * FROM solution ORDER BY level, id"

  Using cmd As SQLiteCommand = New SQLiteCommand(query, db.Connection)

    Using selectResult As SQLiteDataReader = cmd.ExecuteReader()

      Dim mapKeyToItem As Dictionary(Of Integer, SolutionItemModel) = New Dictionary(Of Integer, SolutionItemModel)()

      If selectResult.HasRows = True Then
        If selectResult.Read() = True Then
          Dim root = solutionRoot.AddSolutionRootItem(selectResult("name").ToString())

          ' Gets the Id of Root entry
          mapKeyToItem.Add(selectResult.GetInt32(0), root)
          recordCount += 1
        End If

        While selectResult.Read() = True
          Dim iParentKey As Integer = selectResult.GetInt32(1) ' Get parent key from next item
          Dim parent As SolutionItemModel = Nothing

          If mapKeyToItem.TryGetValue(iParentKey, parent) = True Then
            Dim itemTypeId = CLng(selectResult("itemtypeid"))
            Dim item = parent.AddChild(parent, selectResult("name").ToString(), itemTypeId)

            mapKeyToItem.Add(selectResult.GetInt32(0), item)
            recordCount += 1
          Else
            Throw New Exception("Data written is corrupted.")
          End If
        End While
      End If
    End Using
  End Using

  Return recordCount
End Function

The 2nd mapKeyToItem.Add(...) statement in the above code can be optimized further if we recognize that we need to store only objects that can actually have Children themselves (Folder, Project, RootItem), while File objects cannot have children and, therefore, require no storage for later lookup in the dictionary.

The flat file database system was covered in a recent article [4]. The article developed a SQLite database wrapper class called SQLiteDatabase - this class is reused here and extended with custom functorialities.

We are now able to run the attached SQLite_Demo understanding the concept - be sure to set some breackpoints with the debugger, if something is in doubt or use the forum below the article to ask questions...

So, the concept does work with a simple Console application, but how do we do this with WPF? Should we have the database class consume the data and spit out ViewModel items or is there a better way for this?

The Solution Explorer Demo

The attached WPF demo sample application is a further development of the 00_InplaceEditBoxLib.zip project attached to the 1st article. The application is now multi-threaded and virtualized. There are also some editing functions that can be used to edit, add, or remove items as the user wishes:

The Context-Menu in the above window is implemented in the TreeItemContentPresenter of the  MainWIndow.xaml in the InPlaceEditBoxDemo project. Most of these commands bind directly to an object of the:

SolutionLib.ViewModels.Browser.SolutionViewModel

class, which is the root ViewModel of the solution tree view (ViewModel part).

All ViewModel classes that represent an item (node) in the tree view are either used on the ItemViewModel class or the ItemChildrenViewModel class. This design turned out to be adventegious, because we can clearly distinguish between items that can have items below themselves (Project, Folder, RootItem) and other items that cannot have children (Files).

The Save and Load commands are not implemented in the SolutionViewModel class. Why? Because it turns out that it does make sense to convert a viewmodel representation into a model representation before saving it. There are various arguments for doing so, the most important ones are:

  1. The view and the viewmodel should not directly depend on the underlying data layer - this means we should be able to exchange different storage technologies (eg.: XML, relational database etc) without having to re-write the entire application
     
  2. A viewmodel usually contains items that are useful when the UI is alive, such as, the currently selected node. Most of this UI and state relevant data is not required for persistence.

    Now, we could argue that it could be required to know what the last selected item was in order to re-establish that state on re-load of the tree view. And thats a valid point - but if we consider to re-establish the state of a tree view after reloading its data we should do so based on 2 files (one configuration file for the tree structured data and one session file for its last states) rather than writing everything in one file.

    This seperation of configuration and state is a good practice, because users may wish to store the settings file in GitHUB (or elsewhere), while the last session may not always be that interesting for everyone else...

So, these are only 2 important points that lead us to the conclusion that a viewmodel should almost always be converted into a model before being stored - and the retrieval will then require us to retrieve the model, convert it into a viewmodel to display it's data in the bound view.

The SaveSolutionCommand and the LoadSolutionCommand are implemented in the AppViewModel class of the InPlaceEditBoxDemo project. Both commands accepts an ISolution object as parameter and call a method in turn. Here is the method called by the SaveSolutionCommand:

private async Task SaveSolutionCommand_ExecutedAsync(ISolution solutionRoot)
{
  var explorer = ServiceLocator.ServiceContainer.Instance.GetService<IExplorer>();
  var filepath = explorer.SaveDocumentFile(UserDocDir + "\\" + "New Solution",
                                           UserDocDir, true, solutionRoot.SolutionFileFilter);

  if (string.IsNullOrEmpty(filepath) == true) // User clicked Cancel ...
    return;

  // Convert to model and save model to file system
  var solutionModel = new ViewModelModelConverter().ToModel(solutionRoot);
  var result = await SaveSolutionFileAsync(filepath, solutionModel);
}
Private Async Function SaveSolutionCommand_ExecutedAsync(ByVal solutionRoot As ISolution) As Task
  Dim explorer = ServiceLocator.ServiceLocator.ServiceContainer.Instance.GetService(Of IExplorer)()

  Dim filepath = explorer.SaveDocumentFile(UserDocDir & "\" & "New Solution",
                                           UserDocDir,
                                           True,
                                           solutionRoot.SolutionFileFilter)

  If String.IsNullOrEmpty(filepath) = True Then Return  ' User clicked Cancel ...

  ' Convert to model and save model to file system
  Dim solutionModel = New ViewModelModelConverter().ToModel(solutionRoot)
  Dim result = Await SaveSolutionFileAsync(filepath, solutionModel)
End Function

The last 2 lines in the above code snippet show how the Model for the Solution is obtained from the ViewModelModelConverter().ToModel() call. This Model (and not the ViewModel) is then saved through an extra call to SaveSolutionFileAsync(....). So, let us review these in the next sections.

Level-Order Conversion (Revisited)

There is this Level-Order Conversion section in the previous article that gave hints towards converting a Model to a ViewModel (on Load) and claimed this should also be possible in the opposite direction (on Save). This conversion is implemented in both directions in the

InPlaceEditBoxDemo.ViewModels.ViewModelModelConverter

class. The conversion methods ToModel() and ToViewModel() are used within the SaveSolutionCommand and the LoadSolutionCommand in the AppViewModel class. The ToModel() method is called before the Save operation takes place.

The conversion is based on the Level-Order algorithm of the TreeLib library project. The foreach loop below does the actual conversion, also based on an Id that is computed on the fly in itemId.

public SolutionModelsLib.Models.SolutionModel ToModel(ISolution solutionRoot)
{
  IItem treeRootVM = solutionRoot.GetRootItem();
  long itemId = 0;

  var items = TreeLib.BreadthFirst.Traverse.LevelOrder(treeRootVM
              , (i) =>
              {
                  var it = i as IItemChildren;

                  if (it != null)
                      return it.Children;

                  // Emulate an emtpy list if items have no children
                  return new List<IItemChildren>();
              });

  var dstIdItems = new Dictionary<long, IItemChildrenModel>();
  var solutionModel = new SolutionModel();

  foreach (var item in items.Select(i => i.Node))
  {
    item.SetId(itemId++);

    if (item.Parent == null)
    {
        solutionModel.AddSolutionRootItem(item.DisplayName, item.GetId());
        dstIdItems.Add(solutionModel.Root.Id, solutionModel.Root);
    }
    else
    {
      IItemChildrenModel modelParentItem;
      IItemModel modelNewChild;
      dstIdItems.TryGetValue(item.Parent.GetId(), out modelParentItem);

      modelNewChild = ConvertToModel(solutionModel, modelParentItem, item);

      modelNewChild.Id = item.GetId();

      // Store only items that can have children for later lock-up
      if (modelNewChild is IItemChildrenModel)
        dstIdItems.Add(modelNewChild.Id, modelNewChild as IItemChildrenModel);
    }
  }

  return solutionModel;
}
Public Function ToModel(ByVal solutionRoot As ISolution) As SolutionModelsLib.Models.SolutionModel
  Dim treeRootVM As IItem = solutionRoot.GetRootItem()
  Dim itemId As Long = 0

  Dim items = TreeLib.BreadthFirst.Traverse.LevelOrder(treeRootVM, Function(i)
    Dim it = TryCast(i, IItemChildren)
    
    If it IsNot Nothing Then Return it.Children

    ' Emulate an emtpy list if items have no children
    Return New List(Of IItemChildren)()
  End Function)

  Dim dstIdItems = New Dictionary(Of Long, IItemChildrenModel)()
  Dim solutionModel = New SolutionModel()

  For Each item In items.[Select](Function(i) i.Node)
    item.SetId(Math.Min(System.Threading.Interlocked.Increment(itemId), itemId - 1))

    If item.Parent Is Nothing Then
        solutionModel.AddSolutionRootItem(item.DisplayName, item.GetId())
        dstIdItems.Add(solutionModel.Root.Id, solutionModel.Root)
    Else
        Dim modelParentItem As IItemChildrenModel = Nothing
        Dim modelNewChild As IItemModel = Nothing

        dstIdItems.TryGetValue(item.Parent.GetId(), modelParentItem)
        modelNewChild = ConvertToModel(solutionModel, modelParentItem, item)

        modelNewChild.Id = item.GetId()
        
        ' Store only items that can have children for later lock-up
        If TypeOf modelNewChild Is IItemChildrenModel Then dstIdItems.Add(modelNewChild.Id, TryCast(modelNewChild, IItemChildrenModel))
    End If
  Next

  Return solutionModel
End Function

The above code converts the ViewModel objects in the SolutionLib project (using one more helper method) into a collection of model objects defined in the SolutionModelsLib project. The model definitions in the SolutionModelsLib project are defined in a very similar structure to the viewmodels in the SolutionLib project. This similarity is mainly there, because it makes the conversion much easier. Note, however that both, model and viewmodel structure could also be very different depending on the requirements of the UI and the storage technology used.

Level-Order Storage

The previous section explained the conversion from the ViewModel into the Model. This section explains how the model is stored via the AppViewModel.SaveSolutionFileAsync() method call. The SaveSolutionFileAsync() method calls the SaveSolutionFile() method, which in turn calls the SQLite specific methods that implement the backend. The interesting line here is this statement:

// Write solution tree data file
recordCount = db.InsertSolutionData(solutionRoot);

which leads us to the WriteToFile Method in the SolutionModelsLib.SQLite.SolutionDB class:

private int WriteToFile(SolutionModel solutionRoot
                      , SQLiteCommand cmd)
{
  int result = 0;
  int iKey = 0;

  var items = TreeLib.BreadthFirst.Traverse.LevelOrder<IItemModel>(solutionRoot.Root
              , (i) =>
              {
                  var it = i as IItemChildrenModel;

                  if (it != null)
                      return it.Children;

                  // Emulate an emtpy list if items have no children
                  return new List<IItemChildrenModel>();
              });

  foreach (var item in items)
  {
    int iLevel = item.Level;
    IItemModel current = item.Node;
    current.Id = iKey++;

    long parentId = (current.Parent == null ? -1 : current.Parent.Id);

    if (cmd != null)
    {
      cmd.Parameters.AddWithValue("@id", current.Id);
      cmd.Parameters.AddWithValue("@parent", parentId);
      cmd.Parameters.AddWithValue("@level", iLevel);
      cmd.Parameters.AddWithValue("@name", current.DisplayName);
      cmd.Parameters.AddWithValue("@itemtypeid", (int)(current.ItemType));

      result += cmd.ExecuteNonQuery();
    }
    else
    {
      Console.WriteLine(string.Format("{0,4} - {1} ({2})"
          , iLevel, current.GetStackPath(), current.ItemType.ToString()));
    }
  }

  return result;
} 
Private Function WriteToFile(ByVal solutionRoot As SolutionModel,
                           ByVal cmd As SQLiteCommand) As Integer
  Dim result As Integer = 0
  Dim iKey As Integer = 0

  Dim items = TreeLib.BreadthFirst.Traverse.LevelOrder(Of IItemModel)(solutionRoot.Root,
    Function(i)
      Dim it = TryCast(i, IItemChildrenModel)

      If it IsNot Nothing Then Return it.Children

      ' Emulate an emtpy list if items have no children
      Return New List(Of IItemChildrenModel)()
    End Function)

  For Each item In items
    Dim iLevel As Integer = item.Level
    Dim current As IItemModel = item.Node

    current.Id = Math.Min(System.Threading.Interlocked.Increment(iKey), iKey - 1)

    Dim parentId As Long = (If(current.Parent Is Nothing, -1, current.Parent.Id))

    If cmd IsNot Nothing Then
        cmd.Parameters.AddWithValue("@id", current.Id)
        cmd.Parameters.AddWithValue("@parent", parentId)
        cmd.Parameters.AddWithValue("@level", iLevel)
        cmd.Parameters.AddWithValue("@name", current.DisplayName)
        cmd.Parameters.AddWithValue("@itemtypeid", CInt((current.ItemType)))
        result += cmd.ExecuteNonQuery()
    Else
        Console.WriteLine(String.Format("{0,4} - {1} ({2})", iLevel, current.GetStackPath(), current.ItemType.ToString()))
    End If
  Next

  Return result
End Function

This method looks very similar to the converter method shown in the previous section. Here, we are using the same traversal method from the TreeLib library project. The foreach loop is (again) the part that does the actual operation on each tree node - the operation here being data storage in the SQLite database file.

Conclusions

Many beginners in programming have problems manipulating tree structured data items, which makes their WPF tree view implementation limited to some degree. This article and the previous article are published in the hope that traversal methods can shed some warm light here. The usage of the IEnumerable/Yield method, described in the last article and re-used here for conversion and load/save, shows that an application of these traversal methods can be trivial, while still yielding outstanding performance.

The extra conversion between model and viewmodel seems to be extra work without any real immediate advantage. But one should draw a benefit from this design when working in a larger team. This design approach will also yield more flexability when future changes need to be included. Change something significant, like the UI control suite, or the storage backend, or just developing things further should be easy if you stick to working with Models and ViewModels with a proper seperation.

History

  • 2017-12-08 all code samples available VB.Net also

References

License

This article has no explicit license attached to it but may contain usage terms in the article text or the download files themselves. If in doubt please contact the author via the discussion board below.

A list of licenses authors might use can be found here