MSDyn365FO. Add postTargetProcess method to a Data Entity via extension.

Quite a while ago I wrote a blog post about postTargetProcess method that could be added to a data entity for post processing. Previously, you could add it only to a newly created entity, but now you can extend any entity using CoC.

This method is a bit special because it is not declared on the parent object, but is called via reflection by DMFEntityBase class. Full list of method that work in the same way:

  • defaultCTQuery
  • copyCustomStagingToTarget
  • postGetStagingData
  • preTargetProcessSetBased
  • postTargetProcess

DMFEntityBase uses tableHasStaticMethod() to check if a method is defined and DictDataEntity.callStatic() to call it. In the latest PUs Microsoft added support of CoC methods to Dict* API and reflection methods, so now it is possible to use CoC method in this scenario as well.

Extension is quite simple and straightforward, all you need to do is to declare static method with respective signature and do not call next because this method is not defined on the parent object:

[ExtensionOf(tableStr(MyDataEntity))]
final public class MyDataEntity_Extension
{
    public static void postTargetProcess(DMFDefinitionGroupExecution _dmfDefinitionGroupExecution)
    {
        //do stuff
    }

}
Advertisements

MSDyn365FO. AX 2012 data upgrade with virtual companies.

In Dynamics 365 for Finance and Operations virtual companies are deprecated and could not be upgraded according to the documentation that just state this fact without proposing a solution.

If we cannot upgrade, then we need to get rid of them. Here are the high level steps how you can de-virtualize a table. Let’s say that we have virtual company V with two companies: A and B. V has only one table collection with one table for simplicity, let’s call it Table1.

  1. Delete a data from Table1 that belongs to the companies A and B. When you create a virtual company, you may already have some data in the tables you want to share. That data may be orphaned, so we want to delete it to avoid duplicates. Can be done via simple T-SQL script:
    DELETE FROM Table1
    WHERE DATAAREAID = 'A' or DATAAREAID = ‘B’
    
  2. Go to System administration > Setup > Virtual company accounts and delete the virtual company.
  3. Restart AX client.
  4. Insert data from the virtual company to de-virtualized companies via X++ job:
    static void deVirtualizeTables(Args _args)
    {
        DataAreaId          virtualDataAreaId = 'V';
        container           oldCompaniesCon = ['A', 'B'];
    
        VirtualDataAreaList virtualDataAreaList;
    
        void deVirtualizeTable(TableId _tableId)
        {
            int             i;
            DataAreaId      dataAreaId;
            SysDictTable    dictTable = new SysDictTable(_tableId);
            Common          buffer = dictTable.makeRecord();
            Common          newBuffer;
    
            while select crossCompany buffer
                where buffer.dataAreaId == virtualDataAreaId
            {
                for (i = 1; i <= conLen(oldCompaniesCon); i++)
                {
                    dataAreaId = conPeek(oldCompaniesCon, i);
                    changeCompany (dataAreaId)
                    {
                        newBuffer = null;
                        newBuffer = dictTable.makeRecord();
    
                        buf2buf(buffer, newBuffer);
                        newBuffer.doInsert();
                    }
                }
            }
        }
    
        select firstOnly RecId from virtualDataAreaList;
    
        if (virtualDataAreaList)
        {
            throw error('Delete a virtual company first!');
        }
    
        ttsBegin;
    
        deVirtualizeTable(tableNum(Table1));
    
        ttsCommit;
    
    }
    
  5. Delete data from the virtual company. It cannot be done from X++ because you cannot use changeCompany with a company that does not exist. T-SQL:
    DELETE FROM Table1
    WEHERE DATAAREAID = 'V'
    

Now we are ready to run standard data upgrade procedure!

MSDyn365FO. Code to build cross reference data without a full compile.

After 8.1 there is no need to do code upgrade and build standard code supplied by Microsoft or at least we’ve been told so. However, it’s impossible to refresh cross references without building model with Visual Studio tools and we need this because now hotfixes and monthly updates are cumulative and include binaries and X++ code as well, so cross reference data on dev VMs becomes outdated quite quickly.

While MS is working on actual solution, I dug a bit into xppc that compiles X++ code and build cross references and here you are, this code can be used to build cross references for a module without compile, that is way faster!

using Microsoft.Dynamics.AX.Framework.Xlnt.XReference;
using Microsoft.Dynamics.AX.Metadata.XppCompiler;
using System;

class XRefBuilder
{
    static void Main(string[] args)
    {
        try
        {
            string moduleName = "ApplicationCommon";
            string metaDataPath = @"K:\AosService\PackagesLocalDirectory";

            ICrossReferenceProvider xRefProvider = CrossReferenceProviderFactory.CreateSqlCrossReferenceBatchProvider(".", "DYNAMICSXREFDB", moduleName, true, true, new DiagnosticsHandler());
            xRefProvider.StartBatch();
            new MetadataXRefSweeper( metaDataPath, moduleName, xRefProvider, DateTime.MinValue, new DiagnosticsHandler()).Run();
            xRefProvider.CommitBatch();
        }
        catch (AggregateException ae)
        {
            ae.Handle(ex => {
                    Console.WriteLine(ex.InnerException != null ? ex.InnerException.Message : ex.Message);
                return true;
            });

        }
    }
}

Note catch section, it was a big surprise for me, but standard code has lots of compile issues, for example, KanbanMultiDelete action menu item has EnumParameter property populated but EnumTypeParameter is not. In 2012 days it was not possible to do this, but now you can literally type anything into EnumParameter without specifying enum and save it. Cross reference builder would try to find that enum and throw an exception saying that cannot find an enum with empty name. That’s why I have that catch section and that’s what xppc does, just skip all these errors and probably log them somewhere.

Another gotcha here is OutOfMemoryException that you can get with ApplicationSuite model, so don’t forget to handle it as well.

 

UPDATED:

MetadataXRefSweeper build cross references only for objects like EDT or Tables, but does not cover source code. To build cross references for source code you have to actually compile it 😦

 

 

MSDyn365FO. How-to send PDF document to a printer from X++

SendPDFToPrinter.png

In AX 2012 it could be done with 2 lines of X++ code:

#WinAPI
WinApi::shellExecute(fileName,'', fileFolder, #ShellExePrint);

Now it’s not that easy. Files are in Azure BLOB storage instead of a folder, printers are in a local network that is not accessible from the cloud and WinApi is deprecated. To print standard reports Document Routing Agent should be installed. We need it to send PDF directly from the system as well.

Let’s say we want to print PDF file saved in document attachments (DocuRef). First, we need to check if a printer selected by a user is active and get printer details:

SrsReportPrinterContract activePrinterContract = SrsReportRunUtil::getActivePrinter(printerName);
if (!activePrinterContract.parmPrinterName())
{
    return;
}

printerName value should come from somewhere, for example a field on a form. In this case we can use SrsReportRunUtil::lookupPrinters() method to add a lookup with all available printers:

public static void lookupPrinters(FormStringControl _ctrl)
{
    SrsReportRunUtil::lookupPrinters(_ctrl);
}

Then we need to create print destination settings:

SRSPrintDestinationSettings srsPrintDestinationSettings = new SRSPrintDestinationSettings();
srsPrintDestinationSettings.printMediumType(SRSPrintMediumType::Printer);
srsPrintDestinationSettings.fileFormat(SRSReportFileFormat::PDF);
srsPrintDestinationSettings.printerName(activePrinterContract.parmPrinterName());
srsPrintDestinationSettings.printerWhere(activePrinterContract.parmPrinterPath());
srsPrintDestinationSettings.numberOfCopies(1);
srsPrintDestinationSettings.collate(false);
srsPrintDestinationSettings.printOnBothSides(SRSReportDuplexPrintingSetting::None);
srsPrintDestinationSettings.printAllPages(true);
srsPrintDestinationSettings.fromPage(0);
srsPrintDestinationSettings.toPage(0);

srsPrintDestinationSettings.printerWhere() is important bit here. This parameter accepts path to a printer. It’s possible to install multiple DRA on different servers and path for DRA installed on a print server could be different to path for DRA installed on any other server, so watch for this.

To send document we need to read file into a memory stream:

container fileCon = DocumentManagement::getAttachmentAsContainer(_docuRef);
var stream = Binary::constructFromContainer(fileCon).getMemoryStream();

And create new DocumentContract:

DocumentContract documentContract = DocumentContractFactory::Instance.Create(DocumentContractType::Pdf);

documentContract.Name = _docuRef.Name;
documentContract.Contents =  stream.ToArray();
documentContract.TargetType = TargetType::Printer;
documentContract.Settings = srsPrintDestinationSettings.printerPageSettings();
documentContract.ActivityID = newGuid();

If you send multiple documents ActivityID should be initialized for each document. Don’t forget to add a reference to Microsoft.Dynamics.AX.Framework.DocumentContract:

using Microsoft.Dynamics.AX.Framework.DocumentContract;

And finally send the contract to DRA:

SrsReportRunPrinter::sendDocumentContractToDocumentRouter(documentContract);

Whole method:

public static void sendToPrinter(DocuRef _docuRef, str _printerName)
{
    SrsReportPrinterContract activePrinterContract = SrsReportRunUtil::getActivePrinter(_printerName);
    if (!activePrinterContract.parmPrinterName())
    {
        return;
    }

    SRSPrintDestinationSettings srsPrintDestinationSettings = new SRSPrintDestinationSettings();
    srsPrintDestinationSettings.printMediumType(SRSPrintMediumType::Printer);
    srsPrintDestinationSettings.fileFormat(SRSReportFileFormat::PDF);
    srsPrintDestinationSettings.printerName(activePrinterContract.parmPrinterName());
    srsPrintDestinationSettings.printerWhere(activePrinterContract.parmPrinterPath());
    srsPrintDestinationSettings.numberOfCopies(1);
    srsPrintDestinationSettings.collate(false);
    srsPrintDestinationSettings.printOnBothSides(SRSReportDuplexPrintingSetting::None);
    srsPrintDestinationSettings.printAllPages(true);
    srsPrintDestinationSettings.fromPage(0);
    srsPrintDestinationSettings.toPage(0);

    container fileCon = DocumentManagement::getAttachmentAsContainer(_docuRef);
    if (fileCon)
    {
        var stream = Binary::constructFromContainer(fileCon).getMemoryStream();
        if (stream)
        {
            DocumentContract documentContract = DocumentContractFactory::Instance.Create(DocumentContractType::Pdf);
            documentContract.Name = _docuRef.Name;
            documentContract.Contents =  stream.ToArray();
            documentContract.TargetType = TargetType::Printer;
            documentContract.Settings = srsPrintDestinationSettings.printerPageSettings();
            documentContract.ActivityID = newGuid();
SrsReportRunPrinter::sendDocumentContractToDocumentRouter(documentContract);
        }
    }
}

If you want to check documents printed or see if there are any in a queue you can go to Common -> Inquiries -> Document routing status

DRA status

MSDyn365FO. How-to pass generic types to .Net method

sorcery.png

There is a wonderful blog post about Generic types in X++. And as It says, the easiest way to work with generic types in X++ is to create .Net library and expose non-generic interface, because X++ compiler has lots of limitations.

However, if you really want to have some fun and stick to X++, it’s possible with a help of reflection.

Let’s say we have a C# class that accepts  List<string[]> and we want to invoke it from X++:

public class Class1
{
    public static string myMethod(List<string[]> list)
    {
        string res = string.Empty;

        foreach (var array in list)
        {
            foreach (string s in array)
            {
                res += s;
            }
        }
return res;
    }
}

We cannot simply declare:

var stringList = new System.Collections.Generic.List<String[]>();

Looks like X++ parser cannot handle “<” and “[” combination, as it gives “Invalid token ‘<‘. ” compile error.

Therefore, instead of 2 lines of code in C#:

var stringArrayList = new System.Collections.Generic.List<string[]> { new string[] { "a", "b", "c" } };
string res = Class1.myMethod(stringArrayList);

We need a bit more in X++:

System.String string;

//array we want to put into a list
System.String[] stringArray = new System.String[3]();
stringArray.SetValue('a', 0);
stringArray.SetValue('b', 1);
stringArray.SetValue('c', 2);

//Type[] array for MakeGenericType
System.Type[] typeParams = new  System.Type[1]();
typeParams.SetValue(string.GetType().MakeArrayType(), 0);

//instantiate System.Collections.Generic.List<String[]>
System.Type listType = System.Type::GetType("System.Collections.Generic.List`1");
var constructedListType = listType.MakeGenericType(typeParams);
var stringArrayList = System.Activator::CreateInstance(constructedListType);

//invoke "Add" using reflection because we cannot cast it back to List<String[]>
var  bindingFlags =  BindingFlags::Instance | BindingFlags::Public;
System.Reflection.MethodInfo methodsInfo = constructedListType.GetMethod("Add", bindingFlags);
System.Object[] params = new  System.Object[1]();
params.SetValue(stringArray, 0);
methodsInfo.Invoke(stringArrayList, params);

info(Class1::myMethod(stringArrayList));

X++ is lacking in .Net features and often it’s easier to create intermediate C# class library. However, as you can see, some limitations are caused by X++ compiler that could be overcome using reflection.

One more reason to avoid global variables

v2pos5d.jpg

You can find tons of articles and discussions on the web why global variables should be avoided in any programming language. Here is another one that is AX specific.

Recently, I had to deal with a heisenbug and when I got to the bottom of it, I realized that anyone could do the same rookie mistake. I did a quick search and found at least one in standard application, so it’s worth to share and maybe you need to fix it in your code as well!

This bug is related to SysGlobalObjectCache. It is recommended to use it for better performance. To create an entry in the cache we need scope, key and value. Usually we use fixed scope for each task type (i.e. CustVendExternalItemDescriptionExistsCheck). Key usually is a record Id we are searching in DB (i.e. “0001”) and value is a data returned from DB (i.e. InventTable record).  But we often forget that records in different legal entities could have same id and result cached in one company is not valid for another company!

Let’s look at an example. On standard demo data I found an item that has same id (0001) in 2 companies (usrt, frrt). I added external description for it only in one company (usrt). Now I’m using standard findExternalDescription() method of VendExternalItemDescription class that returns external item description. Under the hood it checks if there is a record for given item and vendor combination and caches the result to avoid extra database calls.

In the test job bellow first call to findExternalDescription() method caches false because there is no description for given key. Then I switch to the company where I have a description, but method still returns false because there is an entry in the cache. When the cache is cleared method finally returns true because it does actual search in DB.

class RunnableClass1
{
    public static void main(Args _args)
    {
        ItemId itemId = '0001';
        VendAccount vendAccount = '1001';
        inventDimId inventDimId = 'AllBlank';

        changecompany('frrt')
        {
            //search for an item description. False is cached because no description exists
            boolean found = new VendExternalItemDescription(itemId, InventDim::find(inventDimId), vendAccount).findExternalDescription();
            info(queryValue(found));
        }

        //change company to company where description exists
        changecompany('usrt')
        {
            boolean found = new VendExternalItemDescription(itemId, InventDim::find(inventDimId), vendAccount).findExternalDescription();
            //false is returned because it is cached from previous call
            info(queryValue(found));

            //reset cache
            SysGlobalObjectCache::clearAllCaches();

            found = new VendExternalItemDescription(itemId, InventDim::find(inventDimId), vendAccount).findExternalDescription();
            //true is returned after cache flush
            info(queryValue(found));
        }
    }
}

As you can see, it’s very easy to create similar issue or maybe you already have one, because it is data specific and we rarely test our customizations using multiple companies. From another side, it is even easier to fix it, just add DataAreaId to the cache key!