Skip to content

Instantly share code, notes, and snippets.

@hidegh
Last active December 12, 2016 12:21
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save hidegh/4802fd2c4a2f0b250e853a976146fac8 to your computer and use it in GitHub Desktop.
Save hidegh/4802fd2c4a2f0b250e853a976146fac8 to your computer and use it in GitHub Desktop.
using System;
using System.Collections;
using System.Collections.Generic;
using System.Reflection;
using System.Text;
using System.Text.RegularExpressions;
namespace MyProject.Reporting.ExpressionResolver
{
/// <summary>
/// Examples to expressions:
/// ------------------------
/// Assuming that DataSources containd a data-set the key (name) "d":
///
/// %(d.Uid)
/// %(d.Subjekts.Client.SSN)
/// %(d.Subjekts.Client.DateOfBirth)
/// %(d.Subjekts.Client.DateOfBirth|yyyy-MM-dd HH:mm)
/// %(d.Subjekts.Client.DateOfBirth|yyyy)
/// %(d.Output.AnnualFee)
/// %(d.Output.AnnualFee|N6)
/// %(d.Output.AnnualFee|C)
/// %(d.Output.PaymentSchedules{<CR><LF>}MM-dd)
/// %(d.Output.Param["A"])
/// %(d.Output.PaymentSchedules["2"]MM-dd)
/// %(d.Output.Param["1"])
///
/// formatting sequence must use )) to escape )
/// indexer must use ]] to escape ]
/// joiner must use }} to escape }
/// furthermore joiner may use following special strings: <CR><LF><TAB>
/// </summary>
/// <typeparam name="T">The type of the concrete implementation</typeparam>
public abstract class ExpressionResolver<T>
where T : ExpressionResolver<T>
{
/// <summary>
/// Data-sets.
/// </summary>
public Dictionary<string, object> DataSources { get; protected set; }
/// <summary>
/// FALSE to avoid exception throwing and displaying default value when dataset referenced by the expression is NULL.
/// Default: TRUE;
/// </summary>
public bool ThrowExceptionOnNullDataSet { get; set; }
/// <summary>
/// When no exception is thrown on referencing a non-defined dataset, hen this value should be displayed.
/// A single parameter {0} can be used to display the current NULL dataset name.
/// </summary>
public string DisplayValueForNullDataSet { get; set; }
/// <summary>
/// FALSE to avoid exception throwing and displaying default value when object inside expression is NULL.
/// Default: FALSE;
/// </summary>
public bool ThrowExceptionOnNullObject { get; set; }
/// <summary>
/// When no exception is thrown on null object inside expression, then this value should be displayed.
/// A single parameter {0} can be used to display the expression to the current NULL object.
/// </summary>
public string DisplayValueForNullObject { get; set; }
/// <summary>
/// FALSE to avoid exception throwing and displaying default value.
/// Default: TRUE;
/// </summary>
public bool ThrowExceptionOnPropertyNotFound { get; set; }
/// <summary>
/// When no exception is thrown on property not found, this value is displayed.
/// Parameter {0} can be used to refers to the property name, parameter {1} to refer to the object where property should be available.
/// </summary>
public string DisplayValueForPropertyNotFound { get; set; }
/// <summary>
/// Display string for NULL properties.
/// </summary>
public string DisplayValueForNullProperty { get; set; }
/// <summary>
/// ctor.
/// </summary>
public ExpressionResolver()
{
// set initial values
ThrowExceptionOnNullDataSet = true;
DisplayValueForNullDataSet = "ERROR: dataset {0} not found!";
ThrowExceptionOnNullObject = false;
DisplayValueForNullObject = "";
ThrowExceptionOnPropertyNotFound = true;
DisplayValueForPropertyNotFound = "ERROR: property {0} on object {1} not found!";
DisplayValueForNullProperty = "";
DataSources = new Dictionary<string, object>();
}
/// <summary>
/// Ads a single data-set to the report.
/// Previously set data-sets will be cleared.
/// </summary>
/// <param name="key">The name which is used to reference the dataset</param>
/// <param name="data">The data object</param>
/// <returns></returns>
public T SetSingleDataSource(string key, object data)
{
DataSources.Clear();
DataSources.Add(key, data);
return (T)this;
}
/// <summary>
/// Sets multiple data-sets to the report.
/// Previously set data-sets will be cleared.
/// </summary>
/// <param name="dataSources"></param>
/// <returns></returns>
public T SetDataSources(IDictionary<string, object> dataSources)
{
DataSources.Clear();
foreach (var ds in dataSources)
{
DataSources.Add(ds.Key, ds.Value);
}
return (T)this;
}
public abstract T ProcessCustomExpressions();
/// <summary>
/// REGEX match described:
/// _ (one or more time) and any alpha-numeric without underscore (At least once) then any alpha-numeric w. underscore may occure any time
/// or
/// alpha (one time) then any times any alpha-numeric w. underscore may occure
///
/// EXPLANATION:
/// \w - all alphanumeric and _
/// \W is [^\w]
/// [^\W_] - will match all alphanumeric, except _ (NOT (not alphanumeric or _))
/// [^\W0-9_] - will match all alphanumeric, except 0-9 and _ (NOT (not alphanumeric or 0-9 or _))
/// </summary>
private const string identifierRegex = @"(?:_+[^\W_]\w*|[^\W0-9_]+\w*)";
private const string propertyExpressionRegex =
@"(?<propertyExpression>" +
identifierRegex +
@"(?:\." + identifierRegex + @")*" +
@")";
private const string formatExpressionRegexWithPercentEscaping = @"
(?<formatter>
(?:
\)\) # match the ESCAPED %
| # OR
[^\)] # match anything, except ending character
)*
)";
private const string formatExpressionRegexOptionalWithPercentEscaping = formatExpressionRegexWithPercentEscaping + @"?";
private const string indexerExpressionRegexWithEscaping = @"
(?:\[) # match starting sequence
(?<indexer>
(?:
]] # match the ESCAPED char
| # OR
[^\]] # match anything, except ending character
)*
)
(?:]) # match ending sequence";
private const string joinExpressionRegexWithEscaping = @"
(?:{) # match starting sequence
(?<joiner>
(?:
}} # match the ESCAPED char
| # OR
[^}] # match anything, except ending character
)*
)
(?:}) # match ending sequence";
/// <summary>
/// OUR REGEX SYNTAX:
/// -----------------
/// =%propertyNamesWithDelimiter
/// ends with :
/// %
/// or |formatExpression%
/// or [indexer]formatExpression%
/// or {joiner}formatExpression%
/// </summary>
private const string rdlcExpressionRegex = @"
(?:%\( # match starting sequence" + "\r\n" +
propertyExpressionRegex + "\r\n" +
@"( # ends with" + "\r\n" +
@"(?:\|" + formatExpressionRegexWithPercentEscaping + @")" +
@"|" +
@"(" + indexerExpressionRegexWithEscaping + formatExpressionRegexOptionalWithPercentEscaping + ")" +
@"|" +
@"(" + joinExpressionRegexWithEscaping + formatExpressionRegexOptionalWithPercentEscaping + ")" +
@")? # ends with must be present one or zero time" + "\r\n" +
@"\)) # match end-sequence";
private static Regex rdlcRegex = new Regex(rdlcExpressionRegex, RegexOptions.Compiled | RegexOptions.IgnorePatternWhitespace);
private object GetPropertyValue(string propertyExpression)
{
// initialize
var propertyNames = propertyExpression.Split('.');
var processedExpression = new StringBuilder();
// process 1st part of expression (the data-set)
var propertyNameForDataset = propertyNames.Length > 0
? propertyNames[0]
: propertyExpression;
if (!DataSources.ContainsKey(propertyNameForDataset))
throw new NullReferenceException(string.Format("Dataset: {0} was not found inside the collection!", propertyNameForDataset));
var ds = DataSources[propertyNameForDataset];
if (ds == null)
{
if (ThrowExceptionOnNullDataSet)
throw new NullReferenceException(string.Format("Dataset: {0} is NULL!", propertyNameForDataset));
return DisplayValueForNullDataSet
.Replace("{0}", propertyNameForDataset);
}
processedExpression.Append(propertyNameForDataset);
// now process rest of the expression (object hierarchy)...
PropertyInfo property;
var currentObject = ds;
for (int index = 1; index < propertyNames.Length; index++)
{
var currentPropertyName = propertyNames[index];
// NOTE: first true result may come only after the 1st iteration
if (currentObject == null)
{
if (ThrowExceptionOnNullObject)
throw new NullReferenceException(string.Format("Null value for object {0}", processedExpression));
return DisplayValueForNullObject
.Replace("{0}", processedExpression.ToString());
}
// get the current object's property with the desired (currentPropertyName) name
property = currentObject.GetType().GetProperty(currentPropertyName);
if (property == null)
{
if (ThrowExceptionOnPropertyNotFound)
throw new Exception(
string.Format("Property: {0} not found for object o.{1}", currentPropertyName, processedExpression)
);
return DisplayValueForPropertyNotFound
.Replace("{0}", currentPropertyName)
.Replace("{1}", processedExpression.ToString());
}
// get value for the current property
// and use that value as the new current object
currentObject = property.GetValue(currentObject, null);
// also extend processed expression
processedExpression
.Append(".")
.Append(currentPropertyName);
}
return currentObject;
}
private string FormatValue(object v, string formatter)
{
if (string.IsNullOrWhiteSpace(formatter))
{
// no formatter
if (v == null)
return DisplayValueForNullProperty;
return v.ToString();
}
else
{
// with formatter
return string.Format("{0:" + formatter + "}", v);
}
}
/// <summary>
/// Evaluates expression, replaces expression text with the evaluated value.
/// </summary>
/// <param name="text"></param>
/// <returns></returns>
protected string EvaluateText(string text)
{
// Store original value
var originalValue = text;
// If nothing to check
if (string.IsNullOrWhiteSpace(originalValue))
return originalValue;
// Only process items, where the value is FULLY matched!
var matches = rdlcRegex.Matches(originalValue);
var nextUnprocessedIndex = 0;
var newValue = new StringBuilder();
foreach (Match match in matches)
{ // ### start foreach ###
// paste text between matches
if (nextUnprocessedIndex < match.Index)
{
var length = match.Index - nextUnprocessedIndex;
newValue.Append(originalValue.Substring(nextUnprocessedIndex, length));
}
// update unprocessed index
nextUnprocessedIndex = match.Index + match.Length;
// get expression details
var propExpr = match.Groups["propertyExpression"].Value;
var formatter = match.Groups["formatter"].Value;
var indexer = match.Groups["indexer"].Value;
var joiner = match.Groups["joiner"].Value;
// since formatter is used with string.Format, we must escape the characters { and }
formatter = formatter
.Replace("{", "{{")
.Replace("}", "}}");
// get property details
var o = GetPropertyValue(propExpr);
// handle indexers as 1st
if (!string.IsNullOrWhiteSpace(indexer))
{
// NOTE: - because .net report viewer uses [] as a placeholder, we have to use [""]
indexer = indexer.Trim('"');
// For IDictionary, Hashtable, IList, IDictionary<T, K>, Hashtable<T, K>, Ilist<T> there is an indexer property called Item!
// Problem may occure, if there are more indexers with only differences in the indexing type (list has an indexer for int, hashtable for string, IDictionary<T,K> for T)!
// NOTE: Assume there's only a single indexer - otherise we will get an AmbiguousMatchException!
var oType = o.GetType();
var isList = o is IList;
var isIDictionary = o is IDictionary;
var isHashtable = typeof(Hashtable).IsAssignableFrom(oType); // NOTE: o is Hashtable is always FALSE
var propIndexer = o.GetType().GetProperty("Item");
var hasIndexer = propIndexer != null && propIndexer.GetIndexParameters().Length > 0;
var isIListT = false;
var isIDictionaryTK = false;
if (oType.IsGenericType)
{
if (typeof(IList<>).IsAssignableFrom(oType.GetGenericTypeDefinition())) isIListT = true;
if (typeof(IDictionary<,>).IsAssignableFrom(oType.GetGenericTypeDefinition())) isIDictionaryTK = true;
}
var isIndexedCollection = hasIndexer || isList || isIDictionary || isHashtable || isIListT || isIDictionaryTK;
// check object type against expression
if (!isIndexedCollection)
throw new Exception(string.Format("Indexer expression can be used only with IList/IList<T>/IDictionsry/IDictionary<T,K>/Hashtable/or objects implementing an indexer property! Exception at evaluating: {0}", propExpr));
// now use indexer to fetch our value (note: indexing is supported only for string/int/long)
var indexParams = propIndexer.GetIndexParameters();
if (indexParams.Length != 1)
throw new Exception(string.Format("Null or multiple index parameter found for indexer! Exception at evaluating: {0}", propExpr));
var indexParam0Type = indexParams[0].ParameterType;
object v = null;
if (indexParam0Type == typeof(string))
{
v = propIndexer.GetValue(o, new object[] { indexer });
}
else if (indexParam0Type == typeof(int))
{
var intIndexer = 0;
var intSuccess = int.TryParse(indexer, out intIndexer);
if (intSuccess)
v = propIndexer.GetValue(o, new object[] { intIndexer });
else
throw new Exception(string.Format("Indexer for expression: {0} must be type of: {1}", propExpr, indexParam0Type));
}
else if (indexParam0Type == typeof(long))
{
var longIndexer = 0;
var longSuccess = int.TryParse(indexer, out longIndexer);
if (longSuccess)
v = propIndexer.GetValue(o, new object[] { longIndexer });
else
throw new Exception(string.Format("Indexer for expression: {0} must be type of: {1}", propExpr, indexParam0Type));
}
else
{
throw new NotSupportedException(string.Format("Only string/int/long indexers are supported. Exception at evaluating: {0}", propExpr));
}
// set new value
newValue.Append(FormatValue(v, formatter));
// process next match
continue;
}
// handle ienumerables as 2nd (most indexers are ienumerable)
if (!string.IsNullOrWhiteSpace(joiner))
{
// Fortunatelly IDictionary<T,K>, IList<T>, IList, IEnumerable<T> are all descendants of IEnumerable.
var isEnumerable = o is IEnumerable;
if (!isEnumerable)
throw new Exception(string.Format("Joiner expression can be used only with IEnumerable objects! Exception at evaluating: {0}", propExpr));
// handle special sub-strings
joiner = joiner
.Replace("<CR>", "\r")
.Replace("<LF>", "\n")
.Replace("<TAB>", "\t");
// convert to IEnumerable and process
var enumerableObject = (IEnumerable)o;
var firstItem = true;
var sb = new StringBuilder();
foreach (var v in enumerableObject)
{
if (firstItem)
{
// before first item there's no delimiter
firstItem = false;
}
else
{
// from the 2nd item on we will add delimiter
sb.Append(joiner);
}
// add formatted value
sb.Append(FormatValue(v, formatter));
}
// set new value for node
newValue.Append(sb.ToString());
// process next match
continue;
}
// simple object
newValue.Append(FormatValue(o, formatter));
// process next match
continue;
} // ### end foreach ###
// after processing matches - append rest of the original string after last match (suffix)
if (originalValue.Length >= nextUnprocessedIndex)
{
var suffixLength = originalValue.Length - nextUnprocessedIndex;
newValue.Append(originalValue.Substring(nextUnprocessedIndex, suffixLength));
}
// FINALLY: return new value
return newValue.ToString();
}
}
//
//
//
public class ExpressionResolverForXml : ExpressionResolver<ExpressionResolverForXml>
{
private XmlDocument OriginalXmlDoc { get; set; }
/// <summary>
/// The resulting XML document.
/// Until processing, it's instantiated with the original XmlDocument contents.
/// </summary>
public XmlDocument ResultXmlDoc { get; set; }
/// <summary>
/// ctor.
/// </summary>
/// <param name="inputRdlcStream"></param>
public ExpressionResolverForXml(Stream inputRdlcStream)
: base()
{
// load original RDLC xml, keep original as a copy for multiple processing (do not modify it)
OriginalXmlDoc = new XmlDocument();
OriginalXmlDoc.Load(inputRdlcStream);
// set original as processed...
ResultXmlDoc = (XmlDocument)OriginalXmlDoc.Clone();
}
/// <summary>
/// Processes custom expressions in the supported stream (original xml document) and replaces them with values from the supported data-sources.
/// Without calling this method, the original document will be returned.
/// </summary>
/// <returns></returns>
public override ExpressionResolverForXml ProcessCustomExpressions()
{
// copy source to processed
ResultXmlDoc = (XmlDocument)OriginalXmlDoc.Clone();
// parse and modify processed RDLC xml
if (ResultXmlDoc.HasChildNodes)
ProcessNodes(ResultXmlDoc.ChildNodes);
else
ProcessNode(ResultXmlDoc);
// fluent IF
return this;
}
/// <summary>
/// Saves the processed XML to a stream.
/// </summary>
/// <returns></returns>
public void Save(Stream output)
{
ResultXmlDoc.Save(output);
}
/// <summary>
/// Returns the processed XML as a byte array.
/// </summary>
/// <returns></returns>
public byte[] ToBytes()
{
using (var stream = new MemoryStream())
{
Save(stream);
return stream.ToArray();
}
}
/// <summary>
/// Returns the processed XML as string.
/// </summary>
/// <returns></returns>
public override string ToString()
{
return Encoding.UTF8.GetString(ToBytes());
}
private void ProcessNodes(XmlNodeList xmlNodeList)
{
foreach (XmlNode xmlNode in xmlNodeList)
{
ProcessNode(xmlNode);
}
}
private void ProcessNode(XmlNode xmlNode)
{
// process current node
OnNodeFound(xmlNode);
// process node attributes
if (xmlNode.Attributes != null)
{
var xmlNodeAttributeCollection = xmlNode.Attributes;
for (int i = 0; i < xmlNodeAttributeCollection.Count; i++)
{
var xmlAttr = xmlNodeAttributeCollection[i];
OnNodeFound(xmlAttr);
}
}
// recurse other nodes
if (xmlNode.HasChildNodes)
ProcessNodes(xmlNode.ChildNodes);
}
private void OnNodeFound(XmlNode node)
{
if (!string.IsNullOrWhiteSpace(node.Value))
{
var value = EvaluateText(node.Value);
node.Value = value;
}
}
}
}
@hidegh
Copy link
Author

hidegh commented Dec 9, 2016

Notation:

  1. %(expression)
  2. %(expression|formatting) – where formatting might be |yyyy-MM-dd HH:mm or |N6
  3. %(expression{separator}formatting) – which formats each IEnumerable via the given formatting and joins it into a single result by appending the delimiter. Unfortunately even the had to be handled specially...
  4. %(expression[key]formatting) – which takes the value with the given key of type (int | long | string)

Escaping:
Beside that such complex expressions had to be parsed, we had to escape ) with )) inside the whole expression, ] with ]] inside the indexing expression, and } with }} inside the separator expression.

Input:
To the instance of ExpressionResolverForXml DataSources has to be added (f.e. with key "d" as data). Then by calling ProcessCustomExpressions() the expressions inside the XML will be parsed and replaced by real values.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment