Click here to Skip to main content
65,938 articles
CodeProject is changing. Read more.
Articles / Languages / C#

XTree Part III - Trees, DataSets, and DataTables

4.82/5 (16 votes)
6 Jul 2006CPOL9 min read 1   977  
A general purpose tree combined with automated DataRow management.

Image 1

Introduction

I came across a few articles that discuss loading a tree from a DataSet, but none of the articles I found seemed to use the tree as the primary user interface control while behind the scenes, manipulating the DataSet itself. This article uses the XTree that I've written about previously, showing how an underlying DataSet and its tables are updated when editing the tree. Editing includes creating, deleting, and moving nodes. For this demonstration, I've selected a simple todo list as an example. The todo list consists of a collection of projects, each having tasks. Tasks can have subtasks without limit.

The Object Model

The underlying object model is a Model-View-Controller pattern, as illustrated by this diagram:

Image 2

The View

The View is the XTree control. This is the control the user interacts with, and events and callbacks to the controllers are initiated. The DataView that's on the form is just there for inspecting the project and task tables. The DataView is set up as a read-only control, as it is not intended that the user interact with the underlying tables themselves.

The Controllers

There are two controllers:

  • The root node controller, necessary because it is responsible for returning an index of the project being moved.
  • The row controller, responsible for the project/task/subtask node events.

Note that there is only one row controller regardless of the node type (project or task) or the associated table. More on this next.

The Model

The DataAccessLayer class wraps the DataSet instance, and provides the business logic for creating, adding, and moving project and task rows. Task rows can be moved within a project or subtask, as well as be promoted or demoted. The actual mechanics of how this is done is quite simple, as I will show later on.

The Tree Definition File

As described in my previous articles on the XTree, the XTree is controlled be an XML definition file. In this demo, the definition file is quite simple, describing the top level node, the project subnode, and recursive tasks that can be associated with a project or task/subtask.

XML
<?xml version="1.0" encoding="utf-8"?>
<RootNode Name="Root">
  <Nodes>
    <NodeDef Name="Demo" Text="Demo" IsReadOnly="true" IsRequired="true" 
             TypeName="XTreeTasks.DemoController, XTreeTasks">
      <Nodes>
        <NodeDef Name="Project" Text="Project" IsReadOnly="false" 
                 IsRequired="false" 
                 TypeName="XTreeTasks.RowController, XTreeTasks"
                 TypeData="Projects">
          <ParentPopupItems>
            <Popup Text="Add Project" IsAdd="true" Tag="Add"/>
          </ParentPopupItems>
          <PopupItems>
            <Popup Text="Delete Project" IsRemove="true"/>
          </PopupItems>
          <Nodes>
            <NodeDef Name="Task" Text="Task" IsReadOnly="false" Recurse="true" 
                     TypeName="XTreeTasks.RowController, XTreeTasks"
                     TypeData="Tasks">
              <ParentPopupItems>
                <Popup Text="Add Task" IsAdd="true" Tag="Add"/>
              </ParentPopupItems>
              <PopupItems>
                <Popup Text="Delete Task" IsRemove="true"/>
              </PopupItems>
            </NodeDef>
          </Nodes>
        </NodeDef>
      </Nodes>
    </NodeDef>
  </Nodes>
</RootNode>

Note the attributes TypeName and TypeData. The TypeName attribute is the qualified name of the controller that handles the View events/callbacks. Because I wanted a general purpose implementation for the tree, there is only one controller--the RowController--regardless of the table being manipulated. I needed some additional definition data to specify not only the kind of controller, but the table that it "controls". Rather than code a separate controller for the project and task nodes, there is an additional attribute, the TypeData attribute, which is saved as part of the controller instance. This metadata couples the general purpose row controller to the specific implementation requirements that we are achieving in the demo. In this particular case, the TypeData attribute value is also the table name that the controller manipulates--that makes it easy. Incidentally, the metadata is also necessary for the XTree to determine the node definition given by the controller, via the "controller-to-node dictionary", as illustrated in this code:

NodeDef nodeDef = null;
ControllerInfo ci = new ControllerInfo(inst.GetType(), inst.TypeData);
bool found = controllerToNodeDefMap.TryGetValue(ci, out nodeDef);

The Schema

The schema for the DataSet consists of two tables: Projects and Tasks:

Image 3

A row in the Tasks table is either associated with a project, which therefore makes it a top-level task, or another task, which makes it a subtask. The schema is hardcoded in the DAL:

C#
public void CreateSampleStructure()
{
  DataTable dtProject = new DataTable("Projects");
  DataColumn prjpk=new DataColumn("ID", typeof(Guid));
  DataColumn prjName=new DataColumn("Name", typeof(string));
  DataColumn prjSeq = new DataColumn("Seq", typeof(int));
  dtProject.Columns.AddRange(new DataColumn[] { prjpk, prjName, prjSeq });
  dtProject.PrimaryKey = new DataColumn[] { prjpk };

  DataTable dtTask = new DataTable("Tasks");
  DataColumn taskpk = new DataColumn("ID", typeof(Guid));
  DataColumn taskParentID = new DataColumn("ParentTaskID", typeof(Guid));
  DataColumn taskSeq = new DataColumn("Seq", typeof(int));
  taskParentID.AllowDBNull = true;
  DataColumn taskProjectID = new DataColumn("ProjectID", typeof(Guid));
  taskProjectID.AllowDBNull = true;
  DataColumn taskName = new DataColumn("Name", typeof(string));
  dtTask.Columns.AddRange(new DataColumn[] { taskpk, taskParentID, taskName,
         taskProjectID, taskSeq });
  dtTask.PrimaryKey = new DataColumn[] { taskpk };

  dataSet = new DataSet();
  dataSet.Tables.AddRange(new DataTable[] { dtProject, dtTask });
  dataSet.Relations.Add(new DataRelation("childTask", taskpk, taskParentID));
  dataSet.Relations.Add(new DataRelation("projectTask", prjpk, taskProjectID));
}

Other Metadata

Ideally, two other pieces of information should be part of the metadata that defines the tree to data row association: the field names in the table that map to the tree node's text, and the field name that is used to order the tree's leaves. Rows in a table are usually unordered, however, in a todo list, being able to order the tasks and subtasks is pretty much a requirement, therefore the tables need to support preserving the user's ordering preference, hence the "Seq" field. In this demo, these two metadata items, the name field and the sequence field, are hardcoded during the construction of the DAL.

Implementation

The actual implementation was quite simple given the flexibility of the XTree. I had to make three modifications to the XTree itself--adding the TypeData property, using it to qualify the lookup of the node definition, and changing the AutoDeleteNode method in the controller to return a bool instead of being a void method.

The Row Controller

The row controller implementation is really nothing more than an interface point for the view events, which then calls the business rule methods in the model (the DAL). This is a classic MVC pattern. One thing that's a bit different though is that the controller instances keep a reference to the DataRow associated with the controller, which is of course associated with a specific tree node.

Properties

One of the most vital properties is of course the ability to edit the tree node's text. The row controller implements a Name property which the XTree control uses to get/set the name. Note that the behavior gets the default name if there is no associated row:

C#
public override string Name
{
  get 
  {
    string ret = String.Empty;

    if (dataRow==null)
    {
      ret = base.Name;
    }
    else
    {
      ret = dal.GetNameFieldValue(dataRow);
    }

    return ret;
  }
  set
  {
    if (dataRow==null)
    {
      base.Name = value;
    }
    else
    {
      dal.SetNameFieldValue(dataRow, value);
    }
  }
}

The controller also has a property for the DAL:

C#
public DataAccessLayer DataAccessLayer
{
  get { return dal; }
  set { dal = value; }
}

Now a problem arises. How does the controller get a reference to the DAL instance, if it's the XTree that's instantiating the controller (rather non-MVC'ish in this case)? That question will be answered shortly.

There are more properties, but they are all equally trivial.

Methods

One of the most important internal methods is the Index property. 

C#
/// <summary>
/// Return the sequence number of the project, task or subtask.
/// </summary>
public override int Index(object item)
{
  return dal.GetRowNum(((RowController)item).DataRow);
}

As described in the previous articles, the Index method returns the index of the child item in the controller's item collection. This is necessary for locating the item in the collection and moving it. In this particular case, the underlying collection consists of data rows, which are not quite the same thing. Nevertheless, the controller gets the index from the DAL, which returns the row index of the row as found in a sorted (by sequence) view.

When a node is added, the controller's AddNode method is called. We have a rather simple implementation here:

C#
public override bool AddNode(IXtreeNode parentInstance, string tag)
{
  // Use "as", so that casting non-RowController 
  // instances simply evaluate to a null.
  this.parentInstance = parentInstance as RowController;
  return true;
}

At this point, the controller doesn't have a reference to the DAL instance, so it can't do much. This gets us back to the problem I mentioned earlier, how does the controller get the DAL instance? One more thing (OK, that makes four) I added to the XTree was a NewNode event. The application that has a reference to the DAL uses this event to help out the newly created controller (this code is in the application, not the controller):

C#
/// <summary>
/// Event handler for when a new node is added. This provides the
/// necessary interface so the controller instantiated by the XTree
/// can communicate with the application's DAL to create a row in
/// the appropriate table.
/// </summary>
void OnNewNode(object sender, NewNodeEventArgs args)
{
  RowController rowCtrl = ((RowController)args.InstanceNode);
  rowCtrl.DataAccessLayer = dal;
  rowCtrl.CreateRow(args.NodeDef);
}

Pretty simple. Give the controller a reference to the DAL and tell it to create the row. The controller then communicates with the DAL:

C#
/// <summary>
/// Create a row, initialize fields, and add the row to the 
/// correct table.
/// </summary>
public void CreateRow(NodeDef nodeDef)
{
  // Create the row.
  dataRow=dal.CreateRow(typeData);
  dal.SetNameFieldValue(dataRow, nodeDef.Text);

  // Set the parent ID.
  // A top level row will not have a parent instance that is
  // handled by another row controller (it's the DemoController in
  // our example).
  if (parentInstance != null)
  { 
    dal.SetParentID(dataRow, parentInstance.DataRow);
  }

  // Finish by adding the row to the table.
  dal.AddRow(typeData, dataRow);
}

This is equally simple, except for that odd "if" statement, which verifies that the parent instance is actually another row controller (see the code for AddNode above).

Deleting a node and its associated row couldn't be easier:

C#
/// <summary>
/// Delete the data row. This method typically includes a confirmation dialog.
/// </summary>
public override bool DeleteNode(IXtreeNode parentInstance)
{
  dal.DeleteRow(dataRow);
  return true;
}

Actually, it could be easier. I could delete the DataRow instance directly, but I would prefer to work with the DAL as a common point for manipulating the rows and tables.

Inserting a node (and therefore the row) is simple as well (though this took some thought. Actually, all of this took considerable thought. It's amazing how code can hide the thinking that goes behind it):

C#
public override void InsertNode(IXtreeNode parentInstance, int idx)
{
  RowController parentController = parentInstance as RowController;

  // If the parent controller is the root node or a non-row item, then this
  // is a simple move operation.
  if (parentController == null)
  {
    dal.MoveNode(dataRow, idx);
  }
  else
  {
    // Otherwise, we're potentially moving the row to another parent.
    dal.InsertNode(dataRow, parentController.DataRow, idx);
  }
}

Note that the InsertNode call handles both moving a task within the same project or moving it to another project. Similarly, since we're using the controller for tasks, it handles moving a subtask within the same task or to another task. Or promoting/demoting the task within the task hierarchy. All this magic is done simply by changing the ParentTaskID field value given by the parent controller's DataRow instance. Very cool, if I may say so.

The Data Access Layer

The DAL has some interesting methods as well. The CreateRow method is responsible for creating the row in the appropriate table:

C#
/// <summary>
/// Create a row in the specified table. Called by the RowController
/// to create a row. 
/// </summary>
/// <param name="tableName">The name of the table.</param>
/// <returns>The newly created data row.</returns>
public DataRow CreateRow(string tableName)
{
  // Find the table.
  DataTable dt = dataSet.Tables[tableName];
  // Create the row.
  DataRow dataRow = dt.NewRow();
  // Create a primary key. The assumption here is that
  // there is a single primary key column of type Guid.
  dataRow[dt.PrimaryKey[0]] = Guid.NewGuid();

  return dataRow;
}

Note how it also establishes a unique ID for the primary key. The next step, after the rest of the data has been initialized, is to add the row to the table:

C#
/// <summary>
/// Add the row to the table. All required fields should be set
/// at this point. Called by the RowController after required fields,
/// such as PK's and FK's, have been initialized.
/// </summary>
/// <param name="tableName">The table name to which to add the row.</param>
/// <param name="row">The row to add.</param>
public void AddRow(string tableName, DataRow row)
{
  // Find the table.
  DataTable dt = dataSet.Tables[tableName];

  // This row is appended.
  // If there are no rows, the seq value is 0.
  if (dt.Rows.Count == 0)
  {
    row[seqField] = 0;
  }
  else
  {
    // Otherwise, it's 1+the last row's sequence value.
    // Is there a better way to do this?
    // If we guarantee that there never are holes in the sequence,
    // we could just use dt.Rows.Count, but I think that's a bad
    // assumption to make (and in fact is).
    DataView dv = new DataView(dt);
    dv.Sort = seqField;
    int seq = ((int)dv[dv.Count - 1][seqField]) + 1;
    row[seqField] = seq;
  }

  dt.Rows.Add(row);
}

Here, the sequence field value is established. The code comments explain what's going on. The reason dt.Rows.Count can't be used is that holes are created in the sequence when rows are moved around both laterally and vertically. In part, this is due to my somewhat lame resequencing algorithm.

I don't think I need to show you the DeleteRow method, right? So, the next interesting method is SetParentID. Given the child row and the parent row, the table relationships are searched to determine which column is the foreign key in the child and which is the primary key in the parent. The child's foreign key column is then set appropriately:

C#
/// <summary>
/// Sets the parent ID for the newly created row by inspecting the 
/// Relations collection of the DataSet. Called by the RowController
/// when the node being added is a child node to another RowController
/// instance.
/// </summary>
/// <param name="dataRow">The child row.</param>
/// <param name="parentDataRow">The child's parent row.</param>
public void SetParentID(DataRow dataRow, DataRow parentDataRow)
{
  // Inspect each relationship.
  foreach (DataRelation rel in dataSet.Relations)
  {
    // If the child row's table name matches a relationship's 
    // child table's name and
    // the parent row's table name matches the relationship's 
    // parent table's name...
    if (dataRow.Table.TableName == rel.ChildTable.TableName)
    {
      if (parentDataRow.Table.TableName == rel.ParentTable.TableName)
      {
        // Set the child column field value to the respective parent 
        // column field value.
        // This assumes that only one column is used in the relationship.
        dataRow[rel.ChildColumns[0]] = parentDataRow[rel.ParentColumns[0]];
      }
      else
      {
        // Set the non-parent relationship column to null.
        // This breaks any relationship the child has with a different 
        // parent table.
        // For example, a task is a child of both another task or a project.
        // One of these fields will be set to DBNull, the other to the 
        // correct parent ID.
        dataRow[rel.ChildColumns[0]] = DBNull.Value;
      }
    }
  }
}

But, at the same time, any child columns that are foreign keys to a parent that is not this child's parent are set to null. This breaks the task-project relationship when a task becomes a subtask, or breaks a task-task relationship when a child becomes a top-level task. Interesting, isn't it? I'm also amused by the fact that there are about four lines of comments for every line of code in this method. It's simple and elegant, but not intuitive. As I said before, code does not often reflect the thinking that goes on behind the reason for the code.

I had discussed that the controller gets the row index of a specific row. That's done in this method:

C#
public int GetRowNum(DataRow dataRow)
{
  DataView dv = new DataView(dataRow.Table);
  dv.Sort = seqField;
  int idx = dv.Find(dataRow[seqField]);

  return idx;
}

At this point, you may begin to wonder, why don't I create a DataView dictionary instead of instantiating a DataView every time I need one. Good question. Next!

Now, let's look at InsertNode:

C#
/// <summary>
/// Inserts a row into the parent's row collection at the specified index.
/// </summary>
public void InsertNode(DataRow row, DataRow parentRow, int idx)
{
  // Set the parent ID for the row.
  SetParentID(row, parentRow);
  MoveNode(row, idx);
}

Good grief! Two lines of code. But what it does is very, very powerful. It first updates the appropriate foreign key so that the row is now associated with the desired parent row. Then it moves that row to the appropriate location in the parent's row collection. All the work is being done in SetParentID, which you've seen, and in MoveNode:

C#
/// <summary>
/// The move operation resequences all the rows in the table, regardless
/// of parent. This method has the unfortunate effect of creating holes
/// in the sequencing. This doesn't effect the ordering, but it isn't 
/// "perfect".
/// Yes, there's Rows.InsertAt, but what we are trying to achieve here is
/// preserving the sequence when the table is persisted to the DB and loaded
/// back again.
/// </summary>
public void MoveNode(DataRow row, int idx)
{
  row.Table.AcceptChanges();
  // Get a DataView so we can sort by the sequence value.
  DataView dv = new DataView(row.Table);
  dv.Sort = seqField;
  // Assume we're moving to the end.
  int lastSeq = (int)dv[dv.Count-1][seqField]+1;

  // Bump sequence values until we get to the idx. Scan backwards, so
  // we don't affect the order as we adjust the sequence.
  for (int i = dv.Count - 1; i >= idx; i--)
  {
    DataRowView drv = dv[i];
    int seq = (int)drv[seqField];
    lastSeq = seq;
    drv[seqField] = seq+1;
  }

  // Set the new sequence value, which will always be one less than the
  // last bumped sequence value, unless moving the the row to the end of the
  // collection.
  row[seqField] = lastSeq;
}

This should be self-explanatory. Iterate backwards through the rows, incrementing the sequence values, until we get to the insertion point. Then set the row's sequence field value to the last, unbumped, sequence value.

Conclusion

What?!?! We're done??? Yes. That's it. The demo program includes a simple load/save feature that serializes the DataSet to XML, and everything else is handled by the XTree, which you can read about. Ideally, with just a little bit of metadata, you should be able to create complicated tree structures that can automatically manipulate the associated tables. The DAL that I implemented contains the core functionality, you will most likely need to enhance it to handle different name fields depending on the table, and possibly different sequence fields as well.

License

This article, along with any associated source code and files, is licensed under The Code Project Open License (CPOL)