Click here to Skip to main content
15,867,594 members
Articles / Programming Languages / C#

A Dynamically Generated XML Data Editor

Rate me:
Please Sign up or sign in to vote.
4.88/5 (79 votes)
14 Oct 2003CPOL8 min read 463.4K   9.1K   222   101
Using an XML Schema Definition (XSD) document, this utility dynamically generates a data entry form to create and edit XML data.

Contents

Introduction

My previous utility [^] helped you to create XSD files. After completing this, I hunted around for a utility that would let me manage the data of an associated XML document. Specifically, I wanted two features:

  1. A generic solution that would let me create XML data against any XSD schema
  2. Something that customizes the input control based on schema information

I did not find anything that came close to meeting these two criteria. All of the editors I found were oriented around editing the entire XML document, tags and all, not just the data. For me, the tags are a major encumberance to quickly putting together a data set. Furthermore, the .NET solution, which is to create a CS file, is a complicated, multi-step and non-generic approach.

Thus, I decided to write my own XML data editor and, in the process, learn more about XSD, XML, and XPath. Utilizing the XmlDataDocument, XmlSchema and DataTable classes, I have created a generic editor that dynamically creates specialized controls for data entry, based on the schema definition and table relationships.

An example of the result is: (Sorry about the long picture!)

Screenshot - XmlDataEditor.jpg

...which is derived from the schema (screenshot from my XML Schema Editor):

Screenshot - schema.jpg

Example Files

The download includes an example XSD and XML file. Load the sampleSchema2.XSD file first using the File/Load XSD menu item. Then load the sale5.xml file using the File/Load XML menu item.

Some Background Basics: Importing the Schema and XML Documents

Given:

C#
private XmlSchema schema=null;
private XmlDataDocument doc=null;

The XSD file is loaded:

C#
// get a stream reader for the XSD file
StreamReader tr=new StreamReader(dlgOpen.FileName);
// read the file into the XmlSchema object
schema=XmlSchema.Read(tr, 
  new ValidationEventHandler(SchemaValidationHandler));
tr.Close();
// report any problems with the schema by compiling it
CompileSchema();
// create the document
doc=new XmlDataDocument();
// open the schema again
tr=new StreamReader(dlgOpen.FileName);
// read it into the DataSet member
doc.DataSet.ReadXmlSchema(tr);

The XML file is loaded:

C#
XmlTextReader tr=new XmlTextReader(dlgOpen.FileName);
doc.Load(tr);
tr.Close();

Dynamic GUI Generation

Once the schema has been loaded into the DataSet, the .NET framework automatically creates the associated DataTable objects and their relationships. This information is used to generate the framework for the GUI, which is a series of recursive GroupBox objects encapsulating Control objects, all contained within a panel. The panel provides us with automatic scrolling capabilities, which eleviates the task of managing the scroll functions ourselves.

Get Root Tables

The GUI generation begins by inspecting all root (not parent) tables and determining the XmlSchemaComplexType that defines them. The assumption here is that any root table will be represented in the schema as an element referencing a global complex type.

C#
private void ConstructGUI(DataSet dataSet)
{
  Point pos=new Point(10, 10);
  // get all tables in the dataset
  foreach (DataTable dt in dataSet.Tables)
  {
    // but we're only interested in the toplevel tables
    if (dt.ParentRelations.Count==0)
    {
      /*
       * Rule 1:
       * A top level table will be a top level element in the schema that
       * is of a complex type.  The element name will be the table name.
       * What we want to identify is the complex 
       * type that the table references,
       * so that we can determine the data types of the columns.
       * 
       * Any other rules???
       */
       XmlSchemaElement el=GetGlobalElement(dt.TableName);
       XmlSchemaComplexType ct=GetGlobalComplexType(el.SchemaTypeName.Name);
       Point p2=ConstructGUI(pos.X, pos.Y, dt, pnlDynForm, ct);
       pos.Y+=p2.Y;
       }
    }
}

There are two very simple helper functions to acquire the appropriate objects, illustrated here because of the different mechanism required to obtain them:

GetGlobalElement()

C#
private XmlSchemaElement GetGlobalElement(string name)
{
  XmlQualifiedName qname=new XmlQualifiedName(name, schema.TargetNamespace);
  XmlSchemaObject obj=schema.Elements[qname];
  return obj;
}

GetGlobalComplexType()

C#
private XmlSchemaComplexType GetGlobalComplexType(string name)
{
  for (int i=0; i < schema.Items.Count; i++)
  {
    XmlSchemaComplexType obj=schema.Items[i] as XmlSchemaComplexType;
    if (obj != null)
    {
      if (obj.Name==name)
      {
        return obj;
      }
    }
  }
  return null;
}

Create the GroupBox: A Collection of Records

The GroupBox is created in a straightforward manner, along with a helper hash table called tableInfo. Initially, the group box is not given any size, as this is determined after all the controls have been placed. Also, a navigation bar is created and associated with the group box, providing the first, previous, next, last, new and delete record buttons, as well as the n of m record info text.

C#
private Point ConstructGUI(int absx, int absy, DataTable dt, 
  Control gbParent, XmlSchemaComplexType ct)
{
  GroupBox gb1=new GroupBox();
  gb1.Font=new Font(gb1.Font, FontStyle.Bold);
  gb1.Text=dt.TableName;
  gb1.Location=new Point(absx, absy);
  gb1.Parent=gbParent;
  gb1.Visible=true;

  tableInfo[dt]=new TableInfo();
  CreateRecordNavigationBar(10, 15, gb1, dt);
  ...

Once the group box is placed, the following steps are performed on all columns in the DataTable "dt":

C#
// For each column in the table...
foreach (DataColumn col in dt.Columns)
  {...

1. The placement of the column name and the appropriate control for non-hidden columns:

C#
// if it's not an internal ID...
if (col.ColumnMapping != MappingType.Hidden)
{
  // display its name
  CreateLabel(relx, rely, col.ColumnName, gb1);
  TypeInfo ti=GetTypeInfo(ct, col.ColumnName);
  if (ti != null)
  {
    Control editCtrl=CreateEditControl(relx+120, rely, ti, gb1, dt, col);
  }
}

2. The placement of the column name and read-only text control for hidden fields. This could be omitted, but I left it in so that I could inspect the hidden features of the DataSet. Columns that have relationships with child tables are colored blue and columns that have a relationship with a parent table are colored red (setting the color is not shown here). Because of the nature of schema, these relationships are always 1:1 (as far as I've seen!).

C#
if (col.ColumnMapping==MappingType.Hidden)
{
  Label lbl=CreateLabel(relx, rely, col.ColumnName, gb1);
  Control editCtrl=CreateEditControl(relx+120, 
    rely, new TypeInfo("string"), gb1, dt, col);
  editCtrl.Size=new Size(50, 20);
  ((TextBox)editCtrl).ReadOnly=true;

3. If any child relationships are discovered, the same function is invoked recursively:

C#
// Get child relationships, which are displayed as indented groupboxes
foreach (DataRelation childRelation in dt.ChildRelations)
{
  DataTable dt2=childRelation.ChildTable;
  XmlSchemaElement el2=GetLocalElement(ct, dt2.TableName);
  XmlSchemaComplexType ct2=GetGlobalComplexType(el2.SchemaTypeName.Name);
  if (ct2==null)
  {
    ct2=GetLocalComplexType(el2);
  }
  Point p=ConstructGUI(relx+20, rely+20, dt2, gb1, ct2);

There's also some straightforward math regarding indenting and calculating the size of the outer groupbox, which I'm not going to discuss.

Control Creation

The controls within the group box are created based on some simplistic rules from information gathered from the schema. This area could be greatly expanded because I simply didn't want to implement all the basic types. The rules are as follows:

  1. If the element contains an enumeration, create a ComboBox using the enumeration data.
  2. If the element contains minInclusive and maxInclusive facets, create a NumericUpDown control.
  3. If the element is a Boolean type, create a CheckBox control.
  4. If the element is a decimal or positive integer type, create a right aligned TextBox control.
  5. Everything else is a TextBox control.

Currently, I ignore other facets such as length and pattern.

Record Navigation

Record navigation requires that we manually select child records that are indexed by the parent record ID.

The Basics

These rows are stored in the tableInfo as an array for easy lookup. There are two helper functions that are used extensively. The first adjusts the "record m of n" display for the specified navigator's TextBox. There's a test here to set the position tracker if the table is a root table. This is probably not the best place for this code!

C#
private void UpdateRecordCountInfo(DataTable dt)
{
  // update the text box to reflect the record #
  TableInfo ti=tableInfo[dt] as TableInfo;
  if (dt.ParentRelations.Count==0)
  {
    ti.pos=BindingContext[dt].Position+1;
  }
  ti.tb.Text="   record "+ti.pos.ToString()+" of "+ti.rows.ToString();
}

The GetMatchingRows helper is very useful in determining the child rows that match the parent's ID. Given the child table, the code acquires the parent table and the column that establishes the relationship. It is assumed, rightly so I believe, that there will ever only be one column that defines the relationship. From this, the BindingContext is used to locate the current parent row. Given the parent row, we can acquire the value of the ID and execute a "select" query on the child rows matching the relationship ID.

C#
private int GetMatchingRows(DataTable dt)
{
  TableInfo ti=tableInfo[dt] as TableInfo;
  // get parent relationship
  DataRelation parentRelation=dt.ParentRelations[0];
  // get the parent table
  DataTable dt2=parentRelation.ParentTable;
  // get the parent column (1:1 relationship always)
  DataColumn dcParent=parentRelation.ParentColumns[0];
  // get the current record # of the parent
  int n=BindingContext[dt2].Position;
  if (n != -1)
  {
    // get the ID
    string val=dt2.Rows[n][dcParent].ToString();
    // search the child for all records where child.parentID=parent.ID
    string expr=dcParent.ColumnName+"="+val;
    // save the rows, as we'll use them later on when navigating the child
    ti.dataRows=dt.Select(expr);
  }
  // return the length
  return ti.dataRows.Length;
}

Moving Around

Each handler -- first, previous, next and last -- has basically the same form. I'll demonstrate the "next" handler. Each button in any given navigation bar is "tagged" with the DataTable on which it operates. From this information, the tableInfo object is accessed in order to update the internal record position. Note that for child records, this position is relative to the rows selected by the parent ID, as opposed to a position relative to all the records in the child table.

C#
private void NextRecord(object sender, EventArgs e)
{
  Button btn=sender as Button;
  DataTable dt=btn.Tag as DataTable;
  TableInfo ti=tableInfo[dt] as TableInfo;
  if (ti.pos < ti.rows)
  {
    ((TableInfo)tableInfo[dt]).pos++;
    NextRecord(dt, ti);
    UpdateRecordCountInfo(dt);
  }
}

For this reason, a little bit of manipulation is necessary to acquire the desired record. If the table is a root table, then there is a 1:1 correlation between our internal position and the rows in the DataTable. However, if it is a child table, then we have to find the row in the DataTable that matches our row in the selected rows.

C#
private void NextRecord(DataTable dt, TableInfo ti)
{
    if (dt.ParentRelations.Count==0)
    {
        BindingContext[dt].Position++;
    }
    else
    {
        // get the next row that matches the parent ID
        SetPositionToRow(dt, ti.dataRows[ti.pos-1]);

    }
    ResetAllChildren(dt);
}

This is done with rather brute force and could be optimized to look forward or backward from its current position. Oh, well. Next version...

C#
private void SetPositionToRow(DataTable dt, DataRow row)
{
  for (int i=0; i < dt.Rows.Count; i++)
  {
  if (dt.Rows[i]==row)
    {
      BindingContext[dt].Position=i;
      break;
    }
  }
}

Updating Children

Now, when we change to another record in a parent table, all the children need to be updated to display the set of rows that relate to the parent record. This is accomplished recursively. The matching rows of all children is acquired and the child is set to the first record if it exists. Then the same is done for its children.

C#
private void ResetAllChildren(DataTable dt)
{
  // update all children of the table to match our new ID
  foreach (DataRelation dr in dt.ChildRelations)
  {
    DataTable dtChild=dr.ChildTable;
    ResetChildRecords(dtChild);
  }
}

private void ResetChildRecords(DataTable dt)
{
  int n=GetMatchingRows(dt);
  TableInfo ti=tableInfo[dt] as TableInfo;
  ti.pos=1;
  ti.rows=n;
  UpdateRecordCountInfo(dt);
  if (n != 0)
  {
    SetPositionToRow(dt, ti.dataRows[0]);
  }
  foreach (DataRelation dr in dt.ChildRelations)
  {
    DataTable dtChild=dr.ChildTable;
    ResetChildRecords(dtChild);
  }
}

Adding a Record

Adding a record is rather nasty. Records are added to the end of the collection, which helps a bit. Any relational fields to the parent must be set to the parent ID and all child relationships must have a new record created, as well... Recursively, of course.

C#
private void NewRecord(object sender, EventArgs e)
{
  Button btn=sender as Button;
  DataTable dt=btn.Tag as DataTable;
  dt.Rows.Add(dt.NewRow());
  int newRow=dt.Rows.Count-1;
  BindingContext[dt].Position=newRow;

  TableInfo ti=tableInfo[dt] as TableInfo;

  // Set the child relationship ID's to the parent!
  // There will be only one parent relationship except
  // for the root table.
  if (dt.ParentRelations.Count != 0)
  {
    DataRelation parentRelation=dt.ParentRelations[0];
    DataTable dt2=parentRelation.ParentTable;
    int n=BindingContext[dt2].Position;

    // this is always a 1:1 relationship
    DataColumn dcParent=parentRelation.ParentColumns[0];
    DataColumn dcChild=parentRelation.ChildColumns[0];
    string val=dt2.Rows[n][dcParent].ToString();
    dt.Rows[newRow][dcChild]=val;

    n=GetMatchingRows(dt);
    ti.pos=n;
    ti.rows=n;
  }
  else
  {
    ti.pos=newRow+1;
    ti.rows=newRow+1;
  }

  UpdateRecordCountInfo(dt);

  // for each child, also create a new row in the child's table
  foreach (DataRelation childRelation in dt.ChildRelations)
  {
    DataTable dtChild=childRelation.ChildTable;
    NewRecord(dt, dtChild, childRelation);
  }
}

...and for each child table...

C#
private void NewRecord(DataTable parent, DataTable child, DataRelation dr)
{
  // add the child record
  child.Rows.Add(child.NewRow());

  // get the last row of the parent (this is the new row)
  // and the new row in the child (also the last row)
  int newParentRow=parent.Rows.Count-1;
  int newChildRow=child.Rows.Count-1;

  // go to this record
  BindingContext[child].Position=newChildRow;

  // get the parent and child columns
  // copy the parent ID (auto sequencing) to the child to establish
  // the relationship.  This is always a 1:1 relationship
  DataColumn dcParent=dr.ParentColumns[0];
  DataColumn dcChild=dr.ChildColumns[0];
  string val=parent.Rows[newParentRow][dcParent].ToString();
  child.Rows[newChildRow][dcChild]=val;

  ((TableInfo)tableInfo[child]).pos=1;
  ((TableInfo)tableInfo[child]).rows=1;
  UpdateRecordCountInfo(child);

  // recurse into children of this child
  foreach (DataRelation childRelation in child.ChildRelations)
  {
    DataTable dt2=childRelation.ChildTable;
    NewRecord(child, dt2, childRelation);
  }
}

Deleting a Record

Interestingly, deleting a record is very straightforward. This is thanks to cascading deletes, which the DataSet automatically enables.

C#
private void DeleteRecord(object sender, EventArgs e)
{
  Button btn=sender as Button;
  DataTable dt=btn.Tag as DataTable;
  int n=BindingContext[dt].Position;
  dt.Rows.RemoveAt(n);
  ...

...followed by a bunch of code to display a valid record and reset the children.

Data Binding

Data binding makes life incredibly easy with regards to associating a control with a column in a DataTable, and is accomplished with one line:

C#
ctrl.DataBindings.Add("Text", dt, dc.ColumnName);

This binds the "Text" field of the control to the specified data table and column. Isn't reflection great? The BindingContext is another life-saver. It allows us to specify the row in each DataTable that is used to bind the controls. Awesome! For example, given the table, we can specify the binding row with one line:

C#
BindingContext[dt].Position=dt.Rows.Count;

XPath Queries

Screenshot - XPath.jpg

I implemented XPath capabilities so that I could play with how to extract information from the XML data. Coming from a database background, I wanted something that would flatten the hierarchy so that I could see all the data at once, without needing to mouse-click, etc. through layers of hierarchy. Using the SelectNodes method of the XmlDataDocument:

C#
XmlNodeList nodeList=null;
try
{
  nodeList=doc.SelectNodes(edXPath.Text);
  DlgXPathResult dlg=new DlgXPathResult(nodeList);
  dlg.ShowDialog(this);
}

I extract a node list and, if it's successful, pass it on to a dialog box. The data is displayed in a ListView control with headers extracted from XmlNode. There are three basic steps in displaying node data:

  1. Get the headers.
  2. Fill the rows with data.
  3. Clean up empty columns.

Getting Header Information

The header information is extracted from both the attributes and the elements of the node list. This function operates recursively on elements and therefore can generate unwanted columns. Also, duplicate element names are ignored, which can lead to overwrite problems if your schema uses the same element name in two different areas and you happen to query on those elements.

C#
private void ProcessChildHeaders(XmlNode node, XmlNode child)
{
  while (child != null)
  {
    // process attributes
    if (child.Attributes != null)
   {
      foreach(XmlAttribute attr in child.Attributes)
      {
        if (!cols.Contains(attr.Name))
        {
        cols.Add(attr.Name, colIdx);
        colHasData[colIdx]=false;
        lvResults.Columns.Add(attr.Name, -2, HorizontalAlignment.Left);
        ++colIdx;
        }
      }
    }
    // if this child is an element, get its children
    if (child.FirstChild is XmlElement)
    {
      ProcessChildHeaders(node, child.FirstChild);
    }
    else
    {
      // if not, then either it or its child is a text element
      string name=child.Name=="#text" ? node.Name : child.Name;
      if (!cols.Contains(name))
      {
        // add the column header
       cols.Add(name, colIdx);
       colHasData[colIdx]=false;
       lvResults.Columns.Add(name, -2, HorizontalAlignment.Left);
       ++colIdx;
      }
    }
    child=child.NextSibling;
  }
}

Fill the Rows with Data

This function inspects each child node for attribute values and text values, and recurses into child elements. Some logic is used to prevent empty rows.

C#
private void ProcessChildData(XmlNode node, XmlNode child)
{
  while (child != null)
  {
    ProcessAttributes(child);

    // if this child is an element, get its children
    if (child.FirstChild is XmlElement)
    {
      ProcessChildData(child, child.FirstChild);
      if (hasAttributes | hasData)
      {
        lvi=CreateLVI(cols.Count);
        lvResults.Items.Add(lvi);
        hasData=false;
        hasAttributes=false;
      }
    }
    else
    {
      // if not, then either it or its child is a text element.
      string name=child.Name=="#text" ? node.Name : child.Name;
      int n=(int)cols[name];
      // set the data for the column
      lvi.SubItems[n].Text=child.InnerText;
      hasData=true;
      colHasData[n]=true;
    }
  child=child.NextSibling;
  }
}

Conclusion

After writing all this, I finally feel like I have a set of tools that I can use to generate schemas and manipulate the XML data. While not addressing all the issues of schemas, facets, etc. (the "choice" schema element looks particularly nasty), I feel that I've created a good set of tools for accomplishing 90% for which I need schemas and XML files. If there's any particular feature you feel is a "must have," let me know and I'll try to encorporate it.

History

Oct 15, 2003 - Added support for more complex schemas, fixed some minor bugs

License

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


Written By
Architect Interacx
United States United States
Blog: https://marcclifton.wordpress.com/
Home Page: http://www.marcclifton.com
Research: http://www.higherorderprogramming.com/
GitHub: https://github.com/cliftonm

All my life I have been passionate about architecture / software design, as this is the cornerstone to a maintainable and extensible application. As such, I have enjoyed exploring some crazy ideas and discovering that they are not so crazy after all. I also love writing about my ideas and seeing the community response. As a consultant, I've enjoyed working in a wide range of industries such as aerospace, boatyard management, remote sensing, emergency services / data management, and casino operations. I've done a variety of pro-bono work non-profit organizations related to nature conservancy, drug recovery and women's health.

Comments and Discussions

 
QuestionI get an error opening XSD so can not get this to work. Pin
Member 126719958-Aug-16 7:10
Member 126719958-Aug-16 7:10 
QuestionError "Not found: element1.element2" Pin
Member 1118656621-Jul-15 3:18
Member 1118656621-Jul-15 3:18 
QuestionProblem downloading Pin
Member 1118656620-Jul-15 9:41
Member 1118656620-Jul-15 9:41 
Questionsale5.xml inexistent? Pin
Antonio Barros29-Jan-14 7:07
professionalAntonio Barros29-Jan-14 7:07 
GeneralMy vote of 5 Pin
Amir Mohammad Nasrollahi7-Aug-13 21:34
professionalAmir Mohammad Nasrollahi7-Aug-13 21:34 
QuestionTrouble with a complex message Pin
Ger Hayden11-Jul-13 23:08
Ger Hayden11-Jul-13 23:08 
AnswerRe: Trouble with a complex message Pin
Marc Clifton11-Jul-13 23:37
mvaMarc Clifton11-Jul-13 23:37 
GeneralRe: Trouble with a complex message Pin
Ger Hayden17-Jul-13 9:49
Ger Hayden17-Jul-13 9:49 
GeneralYou beauty Pin
Martin Pyman19-Mar-13 13:44
Martin Pyman19-Mar-13 13:44 
QuestionMultiline TextBox Pin
Member 876304429-Mar-12 23:13
Member 876304429-Mar-12 23:13 
QuestionHow to get similar functionality in web application? Pin
Rajee1-Feb-12 21:47
Rajee1-Feb-12 21:47 
QuestionProblem reading my XSD schema Pin
Member 83652361-Nov-11 2:40
Member 83652361-Nov-11 2:40 
AnswerRe: Problem reading my XSD schema Pin
Marc Clifton1-Nov-11 3:26
mvaMarc Clifton1-Nov-11 3:26 
QuestionData Binding Issue Pin
pchak6-Oct-11 5:05
pchak6-Oct-11 5:05 
Hi Marc,
Great job on solving what could be a very complex problem. You app reads very complex schemas, and presents them nicely.

However, it seems that there is an issue with data binding. I populate a few elements, and upon save, get only an xml containing <newdataset>.

If I query the datatables, I find no rows anywhere that I have put data (before or after acceptchanges).

Any ideas?

Thanks,
Bill
Bill Polewchak
Sr. Technical Design Lead
SolutionArchitecture
Avery Dennison Corp.

QuestionData validation Pin
asd2022714-Sep-11 20:44
asd2022714-Sep-11 20:44 
AnswerRe: Data validation Pin
Marc Clifton15-Sep-11 2:39
mvaMarc Clifton15-Sep-11 2:39 
GeneralRe: Data validation Pin
asd2022715-Sep-11 23:17
asd2022715-Sep-11 23:17 
AnswerRe: Data validation Pin
Member 123610971-Mar-16 22:44
Member 123610971-Mar-16 22:44 
QuestionXUME? Pin
Ger Hayden27-Jun-11 0:25
Ger Hayden27-Jun-11 0:25 
GeneralError reading a big schema Pin
luotope26-Nov-09 5:40
luotope26-Nov-09 5:40 
GeneralRe: Error reading a big schema Pin
Marc Clifton26-Nov-09 11:03
mvaMarc Clifton26-Nov-09 11:03 
GeneralNeed Marc Clifton Contact Info Pin
hussain2luv28-Apr-08 8:37
hussain2luv28-Apr-08 8:37 
Questionerror with GetLocalElement(....) function [modified] Pin
hussain2luv17-Apr-08 5:44
hussain2luv17-Apr-08 5:44 
Generalhelp needed plzzzzzzzzzzzz Pin
hussain2luv13-Apr-08 9:33
hussain2luv13-Apr-08 9:33 
GeneralNeed Help Plzzzzzzzzzzzzzzzzzzz Pin
hussain2luv8-Apr-08 11:17
hussain2luv8-Apr-08 11:17 

General General    News News    Suggestion Suggestion    Question Question    Bug Bug    Answer Answer    Joke Joke    Praise Praise    Rant Rant    Admin Admin   

Use Ctrl+Left/Right to switch messages, Ctrl+Up/Down to switch threads, Ctrl+Shift+Left/Right to switch pages.