Transactional Report Writing - Pt 3

  • Comments 4

 
This is Part 3 of a 4 part article that covers how to create your own end-user-report writer for Dynamics AX. The previous article can be found here: [https://community.dynamics.com/ax/b/dynamicsax_wpfandnetinnovations/archive/2013/07/20/transactional-report-writing-pt-2.aspx#.UfTSq43FX3Q]
 
The next step is to allow the users to add their own calculations to the report. This is probably the most controversial article of this series (so turn away if you easily offended by the phrase security vulnerabilities).
 
I’ve designed a screen like this that allows a number of additional (calculated fields) to be added to the report.
 
 
I didn’t have the time (or energy) to define a syntax-parser or expression-evaluator. Fortunately, this functionality is actually already built into the framework in the form of the runBuf and evalBuf functions. For more information on how to package these functions for use on forms please refer to one of my previous articles: [https://community.dynamics.com/ax/b/dynamicsax_wpfandnetinnovations/archive/2013/03/10/limitations-of-the-office-connector.aspx#.UfTTC43FX3Q]
 
The expression entered into the calculation field must be a “well-formed” X++ script. The script has some subtle differences in that it can contain replacement-variable or substitution-placeholders (these are represented in my solution as square brackets, but you can use whatever symbol you want). These are necessary because you want your expression to evaluate for each row of the query using the values within that record. You are in effect you are adding enhanced calculation functionality to a running query. The following expression illustrates what I’m talking about. It returns a string representation of two fields added together that are within the query:
 
return num2str(([LedgerJournalTrans_AmountCurDebit] + [LedgerJournalTrans_CashDiscAmount]), 0, 2, 1, 0);
 
This is a huge leap in terms of flexibility because it allows you to change the runtime behaviour of queries without having to write specialised classes. It also means that implementors can make runtime adjustments to reports without having to undergo development/deployment cycles.
 
But with great-power comes great-responsibility and there are no restrictions on what you can run (pretty much every function is allowed within the expression). So a production version of this solution would have to incorporate a sanitisation layer that would perform filtering of any dangerous commands. Alternatively, access to the Calculations tab should be limited only to development or implementation staff.
 
After field substituted and expression evaluation has taken place then the result is cast to the required data-type. The process of casting ensures that the data column is handled correctly within the final rendered SSRS report (justification, number-formatting, aggregation etc).
  
The following code sample illustrates how data is retrieved from the query and used to perform value substitution within the dynamic code expressions. As the loop iterates, an Xml representation of the Dataset is gradually built up for processing later by the .Net managed host control.
 
X++
// define the xml table data
xmlData = '<?xml version="1.0" encoding="utf-16"?>';
xmlData += '<DocumentElement>';
 
// reset and execute query
recordCount = 0;
objQueryRun.reset();
while (objQueryRun.next() && recordCount < maxRecords)
{
    dynamicCode1 = StringEditCalculatedValue1.text();
 
    strXmlRecord = '<dt>';
    for (i = 0; i < ListboxSelectedFields.count(); i++)
    {
        // get string value of selected query field
        strTableName = conPeek(fieldList, (i+1)*2-1);
        strFieldName = conPeek(fieldList, (i+1)*2);
        strFieldValue = this.queryFieldValue(strTableName, strFieldName);
        strXmlRecord += '<' + '_' + int2str(i) + '>' + strFieldValue + '</' + '_' + int2str(i) + '>';
 
        // perform value substitution within dynamic code as required
        if (dynamicCode1 != "")
        {
            dynamicCode1 = strReplace(dynamicCode1, '[' + strTableName + '_' + strFieldName + ']', strFieldValue);
            dynamicCondition1 = strReplace(dynamicCondition1, '[' + strTableName + '_' + strFieldName + ']', strFieldValue);
        }
    }
    // execute dynamic code to perform any user-defined calculations
    if (dynamicCode1 != "")
    {
        i++;
        dynamicCode1 = "str calc1() { " + dynamicCode1 + " }";
        strXmlRecord += '<' + '_' + int2str(i) + '>' + PowerSQuirreL::runScript(dynamicCode1) + '</' + '_' + int2str(i) + '>';
    }
 
    // execute dynamic condition to perform any user-defined filtering
    if (dynamicCondition1 != "")
    {
        dynamicCondition1 = "str condition1() { " + dynamicCondition1 + " }";
        if (PowerSQuirreL::runScript(dynamicCondition1) != "true") strXmlRecord = "";
    }
    if (strXmlRecord != "")
    {
        strXmlRecord += '</dt>';
        recordCount++;
    }
    xmlData += strXmlRecord;
}
xmlData += '</DocumentElement>';
 
 
The above function uses the AnyType data type to extract field values and perform string replacement. Unfortunately, the native data type doesn’t intuitively convert its value to string very easily and you’ll need to use this helper function:
 
X++
str queryFieldValue(str _tableName, str _fieldName)
{
    Common                  objcommon;
    anytype                 anyField;
    ;
 
    objCommon = objQueryRun.get(tableName2id(_tableName));
    anyField = objcommon.getFieldValue(_fieldName);
 
    switch (typeof(anyField))
    {
        case Types::UtcDateTime:
            return datetime2str(anyField);
        case Types::Date:
            return date2str(any2date(anyField), 321, DateDay::Digits2, DateSeparator::Hyphen, DateMonth::Digits2, DateSeparator::Hyphen, DateYear::Digits4);
        case Types::Enum:
            return enum2str(any2enum(anyField));
        case Types::Guid:
            return guid2str(any2guid(anyField));
        case Types::Int64:
            return int642str(any2int64(anyField));
        case Types::Integer:
            return int2str(any2int(anyField));
        case Types::Real:
            return num2str(any2real(anyField), 1, 2, 1, 0);
    }
    return ('<![CDATA[' + any2str(anyField) + ']]>');
}
 
 
The Xml Dataset needs to be accompanied by a schema document. This is derived by looping through the selected fields and converting the base data type to the .Net equivalent. The following code illustrates:
 
X++
// define the xml data schema and field list
xmlSchema = '<?xml version="1.0" encoding="utf-16"?>';
xmlSchema += '<xs:schema id="ds" xmlns="" xmlns:xs="http://www.w3.org/2001/XMLSchema" xmlns:msdata="urn:schemas-microsoft-com:xml-msdata">';
xmlSchema += '<xs:element name="ds" msdata:IsDataSet="true" msdata:MainDataTable="dt" msdata:UseCurrentLocale="true">';
xmlSchema += '<xs:complexType>';
xmlSchema += '<xs:choice minOccurs="0" maxOccurs="unbounded">';
xmlSchema += '<xs:element name="dt">';
xmlSchema += '<xs:complexType>';
xmlSchema += '<xs:sequence>';
for (i = 0; i < ListboxSelectedFields.count(); i++)
{
    // slice-up selected field
    strFieldTotal = "false";
    strWorking = ListboxSelectedFields.getText(i);
    if (subStr(strWorking, strLen(strWorking), 1) == "*")
    {
        strFieldTotal = "true";
        strWorking = subStr(strWorking, 1, strLen(strWorking)-1);
    }
    j = strFind(strWorking, ".", 1, strLen(strWorking));
    k = strFind(strWorking, "[", 1, strLen(strWorking));
    strTableName = subStr(strWorking, 1, j-1);
    strFieldLabel = subStr(strWorking, j+1, k-j-1);
    strFieldName = subStr(strWorking, k+1, strLen(strWorking)-k-1);
    fieldList += [strTableName, strFieldName];
 
    // determine base datatype
    dictField = new DictField(tableName2id(strTableName), fieldName2id(tableName2id(strTableName), strFieldName));
    strFieldType = "";
    switch (dictField.baseType())
    {
        case Types::Int64:
            strFieldType = "long";
            break;
        case Types::Integer:
            strFieldType = "int";
            break;
        case Types::Real:
            strFieldType = "decimal";
            break;
        case Types::String:
            strFieldType = "string";
            break;
        case Types::Date:
            strFieldType = "string";
            break;
        case Types::Time:
            strFieldType = "string";
            break;
        case Types::UtcDateTime:
            strFieldType = "dateTime";
            break;
        default:
            strFieldType = "string";
            break;
    }
    xmlSchema += '<xs:element name="' + "_" + int2str(i) + '" type="xs:' + strFieldType + '" minOccurs="0" />';
}
// append any calculated fields to the schema
if (StringEditCalculatedField1.text() != "")
{
    i++;
    xmlSchema += '<xs:element name="' + "_" + int2str(i) + '" type="xs:' + ListboxCalculatedType1.valueStr() + '" minOccurs="0" />';
}
xmlSchema += '</xs:sequence>';
xmlSchema += '</xs:complexType>';
xmlSchema += '</xs:element>';
xmlSchema += '</xs:choice>';
xmlSchema += '</xs:complexType>';
xmlSchema += '</xs:element>';
xmlSchema += '</xs:schema>';
 
 
Once you’ve excuted the query, extracted all the report fields, performed all user-defined calculations and composed the Xml Dataset (and schema) then you are ready to combine and render this with the report-defintion file. I’ll be covering this in the next article.
 
REGARDS
 
 
 
 
  • Have you considered putting the code and AxModel up for download on CodePlex?

  • Interesting thought Joris... I've never considered it. I'll see whats involved in setting up an account. Thanks.

  • Hi Khalid!

    Thank you so much for this interesting project... I am very curious about how you combine the XML data-source and the schema for the final report definition. Hope to see the next blog post soon. Keep up the good work!

  • I'll be uploading the solution (XPO & C# project) to codeplex this weekend so you can analyse for yourself.