Contents
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:
- A generic solution that would let me create XML data against any XSD schema
- 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!)
...which is derived from the schema (screenshot from my XML Schema Editor):
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.
Given:
private XmlSchema schema=null;
private XmlDataDocument doc=null;
The XSD file is loaded:
StreamReader tr=new StreamReader(dlgOpen.FileName);
schema=XmlSchema.Read(tr,
new ValidationEventHandler(SchemaValidationHandler));
tr.Close();
CompileSchema();
doc=new XmlDataDocument();
tr=new StreamReader(dlgOpen.FileName);
doc.DataSet.ReadXmlSchema(tr);
The XML file is loaded:
XmlTextReader tr=new XmlTextReader(dlgOpen.FileName);
doc.Load(tr);
tr.Close();
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.
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.
private void ConstructGUI(DataSet dataSet)
{
Point pos=new Point(10, 10);
foreach (DataTable dt in dataSet.Tables)
{
if (dt.ParentRelations.Count==0)
{
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()
private XmlSchemaElement GetGlobalElement(string name)
{
XmlQualifiedName qname=new XmlQualifiedName(name, schema.TargetNamespace);
XmlSchemaObject obj=schema.Elements[qname];
return obj;
}
GetGlobalComplexType()
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;
}
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.
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":
foreach (DataColumn col in dt.Columns)
{...
1. The placement of the column name and the appropriate control for non-hidden columns:
if (col.ColumnMapping != MappingType.Hidden)
{
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!).
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:
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.
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:
- If the element contains an enumeration, create a
ComboBox
using the enumeration data.
- If the element contains
minInclusive
and maxInclusive
facets, create a NumericUpDown
control.
- If the element is a Boolean type, create a
CheckBox
control.
- If the element is a decimal or positive integer type, create a right aligned
TextBox
control.
- Everything else is a
TextBox
control.
Currently, I ignore other facets such as length and pattern.
Record navigation requires that we manually select child records that are indexed by the parent record ID.
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!
private void UpdateRecordCountInfo(DataTable dt)
{
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.
private int GetMatchingRows(DataTable dt)
{
TableInfo ti=tableInfo[dt] as TableInfo;
DataRelation parentRelation=dt.ParentRelations[0];
DataTable dt2=parentRelation.ParentTable;
DataColumn dcParent=parentRelation.ParentColumns[0];
int n=BindingContext[dt2].Position;
if (n != -1)
{
string val=dt2.Rows[n][dcParent].ToString();
string expr=dcParent.ColumnName+"="+val;
ti.dataRows=dt.Select(expr);
}
return ti.dataRows.Length;
}
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.
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.
private void NextRecord(DataTable dt, TableInfo ti)
{
if (dt.ParentRelations.Count==0)
{
BindingContext[dt].Position++;
}
else
{
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...
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;
}
}
}
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.
private void ResetAllChildren(DataTable dt)
{
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 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.
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;
if (dt.ParentRelations.Count != 0)
{
DataRelation parentRelation=dt.ParentRelations[0];
DataTable dt2=parentRelation.ParentTable;
int n=BindingContext[dt2].Position;
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);
foreach (DataRelation childRelation in dt.ChildRelations)
{
DataTable dtChild=childRelation.ChildTable;
NewRecord(dt, dtChild, childRelation);
}
}
...and for each child table...
private void NewRecord(DataTable parent, DataTable child, DataRelation dr)
{
child.Rows.Add(child.NewRow());
int newParentRow=parent.Rows.Count-1;
int newChildRow=child.Rows.Count-1;
BindingContext[child].Position=newChildRow;
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);
foreach (DataRelation childRelation in child.ChildRelations)
{
DataTable dt2=childRelation.ChildTable;
NewRecord(child, dt2, childRelation);
}
}
Interestingly, deleting a record is very straightforward. This is thanks to cascading deletes, which the DataSet
automatically enables.
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 makes life incredibly easy with regards to associating a control with a column in a DataTable
, and is accomplished with one line:
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:
BindingContext[dt].Position=dt.Rows.Count;
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
:
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:
- Get the headers.
- Fill the rows with data.
- Clean up empty columns.
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.
private void ProcessChildHeaders(XmlNode node, XmlNode child)
{
while (child != null)
{
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 (child.FirstChild is XmlElement)
{
ProcessChildHeaders(node, child.FirstChild);
}
else
{
string name=child.Name=="#text" ? node.Name : child.Name;
if (!cols.Contains(name))
{
cols.Add(name, colIdx);
colHasData[colIdx]=false;
lvResults.Columns.Add(name, -2, HorizontalAlignment.Left);
++colIdx;
}
}
child=child.NextSibling;
}
}
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.
private void ProcessChildData(XmlNode node, XmlNode child)
{
while (child != null)
{
ProcessAttributes(child);
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
{
string name=child.Name=="#text" ? node.Name : child.Name;
int n=(int)cols[name];
lvi.SubItems[n].Text=child.InnerText;
hasData=true;
colHasData[n]=true;
}
child=child.NextSibling;
}
}
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