Download the XPathy source HERE
Background
For a good quick overview of XPath syntax see: http://www.w3schools.com/xpath/xpath_syntax.asp .
Introduction
Parameterized XPaths are not supported in the WPF Data Binding framework, and I needed a solution. Compromises were made, but I am happy to have the capability and am open to improvements that you might volunteer.
Having a data driven application, my task required me to build UI from a semi-persisntent XML file (think Product Catalog), while persisting the user's selections in another. This could not easily be accomplished without parameterized XPaths.
Product Catalog |
|
UI |
|
Saved State |
<Deployments>
<Deployment name="Product Alpha">
<SystemRole name="vSphere Host" />
<SystemRole name="Database Server" />
<SystemRole name="Data Server" />
<SystemRole name="Telephony Server" />
<SystemRole name="Media Server" />
</Deployment>
<Deployment name="Product Beta">
<SystemRole name="MS Terminal Server" />
<SystemRole name="Database Server" />
<SystemRole name="IIS Web Server" />
<SystemRole name="Media Server" />
</Deployment>
</Deployments>
|
>> |
|
>> |
<SiteSurvey Package="Product Alpha">
<System IPDN="mysql.sitedomain.com">
<SystemRole name="vSphere Host" assigned="0" />
<SystemRole name="Database Server" assigned="true" />
<SystemRole name="Data Server" assigned="0" />
<SystemRole name="Telephony Server" assigned="0" />
<SystemRole name="Media Server" assigned="0" />
</System>
<System IPDN="esx.sitedomain.com">
<SystemRole name="vSphere Host" assigned="true" />
<SystemRole name="Database Server" assigned="0" />
<SystemRole name="Data Server" assigned="0" />
<SystemRole name="Telephony Server" assigned="0" />
<SystemRole name="Media Server" assigned="0" />
</System>
</SiteSurvey>
|
Step One (namespace)
Add the source module into your project or build it as a standalone assembly and add it to your window's namespace.
<Window x:Class="CONTROL.SiteSurvey"
...
xmlns:xpy="clr-namespace:XPathy;assembly=XPathy"
...
>
Step Two (resources)
Add the XPathy Converter and the various XmlDataProviders which are used to read and write XML data to drive the XAML UI. Along with the IMultiValueConverter, I show two XmlDataProviders, one for the product catalog and one for the stored user selections.
<Window.Resources>
<xpy:ParameterizedXPathConverter x:Key="XPathConverter" />
<local:XDeploymentDataProvider x:Key="DeploymentXML" />
<local:XSessionDataProvider x:Key="SiteXML" />
</Window.Resources>
Step Three (xaml)
Step Three.One (simple XPath)
I needed a ComboBox for the user to select the product from the catalogue. This kind of XPath is fully supported and common to many applications:
<ComboBox x:Name="_Package"
IsSynchronizedWithCurrentItem="True"
ItemsSource="{Binding Source={StaticResource DeploymentXML}, XPath=Deployments/Deployment, Mode=OneTime}"
DisplayMemberPath="@name"
SelectedValuePath="@name" />
Step Three.Two (OneWay XPath with run-time parametric replacement)
Then I needed a ListBox filled with the product options read from the ComboBox specified section of the XML Product Catalog. This is where I needed a parameterized XPath in the XAML to adapt data read at run-time in response to the user's selection and subsequently change the contents of the ListBox.
<GroupBox Header="System Role(s)">
<ListBox>
<ListBox.ItemsSource>
<MultiBinding Converter="{StaticResource XPathConverter}" ConverterParameter="Deployments/Deployment[@name="{1}"]/SystemRole/@name" Mode="OneWay">
<Binding Source="{StaticResource DeploymentXML}" Mode="OneWay" />
<Binding ElementName="_Package" Path="SelectedValue" Mode="OneWay"/>
</MultiBinding>
</ListBox.ItemsSource>
This introduces the first stages of complexity as it requires a parameterized XPath, but needs only a "OneWay"
binding. The ConverterParameter
is used to specify the parameterized XPath. The first Binding acts as the XML source and the following Bindings as the parameteric values. I use a one based parametric index because the XPath spec uses one based indexing for its predicates. I also model the parameter substitution format after the C# model of using curly braces (e.g. "{1}"). The HTML entity "
must be used to insert a quote character into the XPath, where needed, as the XAML reader gets confused with literals quotes or escaped quotes.
In the converter the XPath would be expanded to something like Deployments/Deployment[@name="Product Alpha"]/SystemRole/@name
, which returns all of the SystemRole names out of the "Product Alpha" section of the Product Calatog. When the user changes the selection of the ComboBox to "Product Beta", then the update-target-binding reruns the converter producing an updated XPath and an updated ListBox from the alternate selection (e.g. Deployments/Deployment[@name="Product Beta"]/SystemRole/@name).
Step Three.Three (TwoWay XPath with run-time parameter replacement)
Lastly, I needed to preserve the user's selections, which required a "TwoWay"
converter and a series of tricks, compromises, limits and workarounds to enable the functionality. Here is the rest of the XAML for the ListBox control.
<ListBox.ItemTemplate>
<DataTemplate>
<StackPanel>
<CheckBox Content="{Binding Path=Item.Value, Mode=OneTime}" >
<CheckBox.Resources>
<xpy:ParameterizedXPathConverter x:Key="lXPathConverter"/>
</CheckBox.Resources>
<CheckBox.IsChecked>
<MultiBinding Converter="{StaticResource lXPathConverter}" Mode="TwoWay" ConverterParameter="/site/SiteSurvey/System[@IPDN="{1}"]/SystemRole[@name="{2}"]/@assigned?Key=cbXPath&DefaultValue=0" >
<Binding Source="{StaticResource SiteXML}" Mode="OneWay" />
<Binding ElementName="_SystemNode" Path="Text" Mode="OneWay" />
<Binding Path="Item.Value" Mode="OneWay"/>
</MultiBinding>
</CheckBox.IsChecked>
</CheckBox>
</StackPanel>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
</GroupBox>
Declaring the converter as a resource of the CheckBox, inside the ItemTemplate of the list is a trick used to give each CheckBox their own in memory instance of the converter. In a "TwoWay"
binding, the ConvertBack() method of the Converter is called with the value of the target control; expecting an array of return values for each bound source. But, from the input and expected outputs, there is no way to determine which ListBoxItem is designated to receive the update. The most elegant compromise that I could find was to keep a copy of the derived XPath with the instance of the converter and use this saved XPath to update the source XmlDataProvider with the target's value. Thus, as a workaround, "TwoWay"
is implemented in the Converter. There is no connection from the expanded XPath back to the source binding through the returned value,...
To facilitate this compromise of retaining instance data I followed the specification for URL parameters and implemented the support to append name-value pairs as parameters to the Converter at the end of the XPath. The question mark character ('?') demarcates the end of the XPath and the begining of Converter parameters or options, each seperated by an ampersand character ('&'). Like quote characters, the ampersand must use the HTML entity "&"
in order to not confuse the XAML compiler. This workaround of using the URL parameter syntax, needed to direct the behavior of the Converter, seemed appropriate as niether the question mark or the ampersand are used within an XPath specification.
When the Converter is used amongst different controls in a window, a window static resource suffices as it can differentiate between the various control instances by the use of different "Key" option values. In the case of the dynamically created controls in my list of CheckBoxes, each CheckBox had to use its own instance of the Converter. Thus even though the "Key" value for each CheckBox was the same they each resolve independently to their own separate instance of their saved XPath. The "Key" parameter is required to facilitate Mode="TwoWay"
binding, specified on the MultiBinding element.
You see here that the XPath has two replacement parameters. There are only external, practical limits to the number of parameters and replacements supported. Just add curly braced placeholders to the XPath string and the corresponding Bindings as replacement parameters as needed. But there are XPath expression limits in the current implementation. The XmlDataProvider class does not directly support the creation of elements or attributes; as it is mostly a reader. Thus to set a Value (or InnerText) specified by an XPath, the elements and attributes must fully exist first. As a workaround, when the "DefaultValue" parameter is specified, the elements and attributes of the XPath will be created in the source XmlDataProvider, with the full path being set to the specified "DefaultValue" provided each and every step in the XPath is unary and unambiguous. The "DefaultValue" parameter is typically required to facilitate Mode="TwoWay"
binding, specified on the MultiBinding element, especially when the source XML is empty. My intention was not to write a full XPath parser, but to support parameterized replacements that identify a single XML node. Thus this workaround for "TwoWay"
binding does not fully support XPaths that use functions or wildcards or non-equal comparisons and will err on the side of node creation in the source XML. In other words, as long as each step in the XPath results in zero or one node from the source XmlDataProvider, "TwoWay"
binding will succeed, and leave a trail.
The implementation also has the limit of not supporting Mode="OneWayToSource"
Binding as the XPath must be expanded during the convertion of the target value prior to being able to push a target value back to the source.
A Peek at the Internals
public class ParameterizedXPathConverter : IMultiValueConverter
{
private Dictionary<string, Tuple<object, string>> pxp_source_xpathf_dic = null;
private static int IndexOfOptions(string parameter)
{
char quotechar = '\0';
for (int i = 0; i < parameter.Length; ++i )
{
switch (parameter[i])
{
case '?':
if (quotechar == '\0')
return i;
break;
case '\'':
case '"':
if (quotechar == '\0')
quotechar = parameter[i];
else if (quotechar == parameter[i])
quotechar = '\0';
break;
}
}
return -1;
}
A Dictionary class is used to track "Key" indexed XmlDataProviders and their expanded XPath strings. Another static function is used to find an unquoted question mark character to demarcate the end of an XPath and the beginning of behavioral options for the Coverter.
public object Convert(object[] values, Type targetType, object parameter, CultureInfo culture)
{
string xpathf = (parameter as string).Replace(""e;", "\"");
for (int i = 1; i < values.Count(); i++)
xpathf = xpathf.Replace("{" + i.ToString().Trim() + "}", values[i] as string ?? "");
int indx = IndexOfOptions(xpathf);
string[] options = (indx == -1) ? null : xpathf.Substring(indx + 1).Split(new char[] { '&' });
if (indx > -1) xpathf = xpathf.Substring(0, indx);
Dictionary<string, string> option_dic = null;
if (indx > -1)
{
option_dic = new Dictionary<string,string>();
foreach (string opt in options)
{
string[] pair = opt.Split(new char[] { '=' }, 2);
if (pair.Count() == 2)
{
if (char.IsPunctuation(pair[1][0]))
pair[1] = pair[1].Trim(pair[1][0]);
option_dic.Add(pair[0].ToLower(), pair[1]);
}
else if (pair.Count() == 1)
option_dic.Add(pair[0].ToLower(), null);
}
}
if (pxp_source_xpathf_dic == null)
pxp_source_xpathf_dic = new Dictionary<string, Tuple<object, string>>();
if (option_dic != null && option_dic.ContainsKey("key"))
{
if (pxp_source_xpathf_dic.ContainsKey(option_dic["key"]))
pxp_source_xpathf_dic.Remove(option_dic["key"]);
pxp_source_xpathf_dic.Add(option_dic["key"], new Tuple<object, string>(values[0], xpathf));
}
string defaultvalue = (option_dic != null && option_dic.ContainsKey("defaultvalue")) ? option_dic["defaultvalue"] : null;
return XPathy.TapXPath(values[0], xpathf, defaultvalue, targetType);
}
Originally I was having trouble with the HTML entity """. As the implementation moved from inline XAML attributes to MultiBinding elements, this non-standard replacement was no longer strictly needed. But I have left it in the opening part of the Convert() function as an inappropriate reminder of the prevous troubles.
The first part of the Convert() function performs all replacements. The loop starts at the second element in the values array, as the first element is the XmlDataProvider. After the replacements have been made, the Converter options are teased out, if any exist. The instance dictionary is created, if needed, and the XmlDataProvider and expanded XPath source are saved if directed by the "Key" option. A "DefaultValue" option is extracted, if one exists.
The inferance that replacements can be made to the options area of the ConverterParameter exists, but should not be used on the "Key" option as the ConvertBack() function has no replacement inputs and will be unable to later match the unexpanded "Key" to the expanded form that will have been cached.
Finally TapXPath() is called to attempt to create the fully resolved XPath in the XmlDataProvider source, if there is a "DefaultValue" and if each step in the path is unarily resolvable. The value from the call to TapXPath() is returned to the Binding layer for use as the updated target value.
public object[] ConvertBack(object value, Type[] targetTypes, object parameter, CultureInfo culture)
{
List<object> l = new List<object>();
int indx = IndexOfOptions(parameter as string);
string[] options = (indx == -1) ? null : (parameter as string).Substring(indx + 1).Split(new char[] { '&' });
Dictionary<string, string> option_dic = null;
if (indx > -1)
{
option_dic = new Dictionary<string, string>();
foreach (string o in options)
{
string[] pair = o.Split(new char[] { '=' }, 2);
if (pair.Count() == 2)
{
if (char.IsPunctuation(pair[1][0]))
pair[1] = pair[1].Trim(pair[1][0]);
option_dic.Add(pair[0].ToLower(), pair[1]);
}
else if (pair.Count() == 1)
option_dic.Add(pair[0].ToLower(), null);
}
}
if (option_dic != null && option_dic.ContainsKey("key") && pxp_source_xpathf_dic.ContainsKey(option_dic["key"]))
{
Tuple<object, string> pxp = pxp_source_xpathf_dic[option_dic["key"]];
if (typeof(bool).Equals(value.GetType()))
XPathy.SetXPaths(pxp.Item1, pxp.Item2, ((value as Nullable<bool>) ?? false) ? "true" : "false");
else
XPathy.SetXPaths(pxp.Item1, pxp.Item2, value as string);
}
foreach (Type t in targetTypes)
{
object o = null;
try { o = System.Convert.ChangeType(value, t); }
catch { o = null; }
finally { l.Add(o); }
}
return l.ToArray();
}
}
The main purpose of the ConvertBack() funtion is the set the value passed in, into the cached source XmlDataProvider at the cached expanded XPath. But to follow protocol, we must return an array of values. Typically, the source bindings will be "OneWay" and will not utilize the returned values in the array. But if a need arises, this function does make a half-hearted attept at appeasing this convention.
As with the Convert() function, the Converter options, if any, are extracted from the ConverterParameter. The XPath portion of the ConverterParameter is ignored in favor of the cached expanded form. The "Key" option is used to lookup the previously cached source XmlDataProvider and the expanded form of the XPath. If the preceding constraints are met, then the value is set in the source XML.
Lastly, a simple attempt is made to return values in the event that one of the source bindings requires it.
Conclusion
I expect this class to continue to meet my parameterized XPath needs, outside of standard WPF data binding, and hope that it will likewise be of value to you. I have packaged the class, HERE with the supporting SetXPath() and TapXPath() functions in a self contained .cs source file. Let me know if you have any improvements.