Monday, March 20, 2023

Separating Plugin Logic: A Guide to Testing Dataverse Plugins with IOC

I’m not a pure TDD developer.  I frequently take my best guess at a Dataverse plugin, then apply TDD until everything works.  This can lead to situations where my “rough draft” plugin is complete, but when I go to write my first test, I realize that I have to test allot, and that’s going to be very painful.  The solution to this is to restructure your plugin code so you can test logic independently of each other.  I ran into having to do this recently and decided that maybe a guide of what I do could be helpful to others.  So, if you ever find yourself in this situation and need a little help, this is the guide for you!

Background

The business requirement in my example is to create a “Total Fees” record per year for contacts, which contained the sum of fees from a grandchild record, where the year was determined by the connecting child record.  This resulted in a data model like this:


The plugin would trigger a recalc of fees for a contact, if:

  1. A grandchild was added
  2. A grandchild was removed.
  3. A grandchild fees was updated
  4. A child was added
  5. A child was removed
  6. A child year was updated

And this is a simplistic view still, since there are plenty of situations where changes shouldn’t trigger a recalc (like the fees being updated from null to 0, or a fee getting added when there is no child id, etc).  For now, let’s abstract all that /* logic */ which gives us these methods in the plugin, with the “OnX” methods being called from the Execute automatically by the plugin base class depending on the context, each each “OnX” method calling the RecalcTotalsForContact method:

private void OnGrandchildChange(ExtendedPluginContext context) { /* logic */ }

private void OnGrandchildCreate(ExtendedPluginContext context) { /* logic */ }

private void OnChildChange(ExtendedPluginContext context) { /* logic */ }

private void OnChildCreate(ExtendedPluginContext context) { /* logic */ }

private void RecalcTotalsForContact(IExtendedPluginContext context, Guid contactId, int year)
{
    context.Trace("Triggering Recalc for Contact {0}, and Year {1}.", contactId, year);

    var yearStart = new DateTime(year, 1, 1, 0, 0, 0, 0, DateTimeKind.Utc);
    var nextYearStart = yearStart.AddYears(1);
    var qe = QueryExpressionFactory.Create<Acme_Grandchild>(v => new { v.Acme_Fees });
    qe.AddLink<Acme_Child>(Acme_Grandchild.Fields.Acme_ChildId, Acme_Child.Fields.Id)
        .WhereEqual(
            Acme_Child.Fields.Acme_ContactId, contactId,
            new ConditionExpression(Acme_Child.Fields.Acme_Year, ConditionOperator.GreaterEqual, yearStart),
            new ConditionExpression(Acme_Child.Fields.Acme_Year, ConditionOperator.LessThan, nextYearStart));

    var totalFees = context.SystemOrganizationService.GetAllEntities(qe).Sum(v => v.Acme_Fees.GetValueOrDefault());
    var upsert = new Acme_ContactTotal
    {
        Acme_ContactId = new EntityReference(Contact.EntityLogicalName, contactId),
        Acme_Name = year + " Net Fees",
        Acme_Total = new Money(totalFees),
        Acme_Year = year.ToString()
    };
    upsert.KeyAttributes.Add(Acme_ContactTotal.Fields.Acme_ContactId, contactId);
    upsert.KeyAttributes.Add(Acme_ContactTotal.Fields.Acme_Year, year.ToString());

    context.SystemOrganizationService.Upsert(upsert);
}

Separating The Logic

When testing, we want to be able to test the “OnX” methods separately from the actual calculation logic in the RecaclTotalsForContact.  In order to do that we will need to be able to inject the calculation logic into the plugin, allowing it to run using a mock object that can be used to verify that the RecalcTotalsForContact was called correctly when testing, and using the actual logic when running on the Dataverse server.

There are 100 different ways to inject the logic into the plugin, but one of the simplest is to encapsulate the RecalcTotalsForContact logic into an interface and inject it into the IServiceProvider that is already in the plugin infrastructure.  Using this approach, the first step is to encapsulate the logic into an IContactTotalCalculator interface (Some purists will never put the interface and the implementation in the file, but if you’re only ever going to have one implementation, IMHO it makes finding the implementation much simpler to be in the same file):

public interface IContactTotalCalculator
{
    void RecalcTotalsForContact(IExtendedPluginContext context, Guid contactId, int year);
}

public class ContactTotalCalculator : IContactTotalCalculator
{
    public void RecalcTotalsForContact(IExtendedPluginContext context, Guid contactId, int year)
    {
        context.Trace("Triggering Recalc for Contact {0}, and Year {1}.", contactId, year);

        var yearStart = new DateTime(year, 1, 1, 0, 0, 0, 0, DateTimeKind.Utc);
        var nextYearStart = yearStart.AddYears(1);
        var qe = QueryExpressionFactory.Create<Acme_Grandchild>(v => new { v.Acme_Fees });
        qe.AddLink<Acme_Child>(Acme_Grandchild.Fields.Acme_ChildId, Acme_Child.Fields.Id)
            .WhereEqual(
                Acme_Child.Fields.Acme_ContactId, contactId,
                new ConditionExpression(Acme_Child.Fields.Acme_Year, ConditionOperator.GreaterEqual, yearStart),
                new ConditionExpression(Acme_Child.Fields.Acme_Year, ConditionOperator.LessThan, nextYearStart));

        var totalFees = context.SystemOrganizationService.GetAllEntities(qe).Sum(v => v.Acme_Fees.GetValueOrDefault())
        var upsert = new Acme_ContactTotal
        {
            Acme_ContactId = new EntityReference(Contact.EntityLogicalName, contactId),
            Acme_Name = year + " Net Fees",
            Acme_Total = new Money(totalFees),
            Acme_Year = year.ToString()
        };
        upsert.KeyAttributes.Add(Acme_ContactTotal.Fields.Acme_ContactId, contactId);
        upsert.KeyAttributes.Add(Acme_ContactTotal.Fields.Acme_Year, year.ToString());

        context.SystemOrganizationService.Upsert(upsert);
    }
}

Then update the plugin to get the IContactTotalCalculator from the ServiceProvider, defaulting to the ContactTotalCalculator implementation if no implementation exists (which won’t on the Dataverse server):

private void RecalcTotalsForContact(IExtendedPluginContext context, Guid contactId, int year)
{
    var calculator = context.ServiceProvider.Get<IContactTotalCalculator>() ?? new ContactTotalCalculator();
    calculator.RecalcTotalsForContact(context, contactId, year);
}

With this simple change, The ContactTotalCalculater is now completely separate from the plugin and can be tested separately with ease!  The plugin triggering logic can now also be tested independently of the actual recalculation logic but there are a few more step required.  Here is a test helper method for the grand children logic that can be called multiple times with different pre-images and targets and the expected children that should be triggered to be recalculated:

private static void TestRecalcTriggered(
    IOrganizationService service,
    ITestLogger logger,
    MessageType message,
    Acme_Grandchild preImage,
    Acme_Grandchild target,
    string failMessage,
    params Acme_Child[] triggeredChildren)
{
    // CREATE LOGIC CONTACT TOTAL CALCULATOR MOCK THAT ACTUALLY DOES NOTHING
    var mockCalculator = new Moq.Mock<IContactTotalCalculator>();
    var plugin = new SumContactFeesPlugin();
    var context = new PluginExecutionContextBuilder()
        .WithFirstRegisteredEvent(plugin, p => p.EntityLogicalName == Acme_Grandchild.EntityLogicalName
                                               && p.Message == message)
        .WithTarget(target);
    if (preImage != null)
    {
        context.WithPreImage(preImage);
    }

    var serviceProvider = new ServiceProviderBuilder(service, context.Build(), logger)
        .WithService(mockCalculator.Object).Build(); // INJECT MOCK INTO SERVICE PROVIDER

    //
    // Act
    //
    plugin.Execute(serviceProvider);

    //
    // Assert
    //
    foreach (var triggeredChild in triggeredChildren)
    {
        mockCalculator.Verify(m =>
                m.RecalcTotalsForContact(It.IsAny<IExtendedPluginContext>(), triggeredChild.Acme_ContactId.Id, triggeredChild.Acme_Year.Year),
            failMessage);
    }

    // VERIFY MOCK CALLED THE EXPECTED # OF TIMES
    try
    {
        mockCalculator.VerifyNoOtherCalls();
    }
    catch
    {
        Assert.Fail(failMessage);
    }
}

Please note that I’m using Moq for my mocking framework and XrmUnitTest for my ServiceProviderBuilder.  You can use any mocking framework/Dataverse Testing framework that you’d like, they’ll all provide the same logic with similar effort.  The key concept is to inject the mock implementation into the IServiceProvider provided to the IPlugin Execute method, and then verify that it has been called the correct number of times with the correct arguments.

Thursday, January 5, 2023

How to Filter Dates in Canvas Apps Using Greater Than/Less Than Operators

Defining the Problem

Recently I was attempting to filter an on-premise SQL table by a DateTime field using a “greater than” operator, and displaying the results in a Data Table control.  When I applied the “greater than” condition to my filter, it would return 0 results.  The crazy thing was I wasn’t seeing any errors.  So I then turned on the Monitor tool and took a look at the response of the getRows request:

{
  "duration": 1130.2,
  "size": 494,
  "status": 400,
  "headers": {
    "Cache-Control": "no-cache,no-store",
    "Content-Length": 494,
    "Content-Type": "application/json",
    "Date": "Thu, 05 Jan 2023 13:36:12 GMT",
    "expires": -1,
    "pragma": "no-cache",
    "strict-transport-security": "max-age=31536000; includeSubDomains",
    "timing-allow-origin": "*",
    "x-content-type-options": "nosniff",
    "x-frame-options": "DENY",
    "x-ms-apihub-cached-response": true,
    "x-ms-apihub-obo": false,
    "x-ms-connection-gateway-object-id": "c29ec50d-0050-4470-ac93-339c4b208626",
    "x-ms-request-id": "e127bd54-0038-4c46-9a31-ce94547c226c",
    "x-ms-user-agent": "PowerApps/3.22122.15 (Web AuthoringTool; AppName=f3d6b68b-f463-43a2-bb2b-b1ea9bd1a03b)",
    "x-ms-client-request-id": "e127bd54-0038-4c46-9a31-ce94547c226c"
  },
  "body": {
    "status": 400,
    "message": "We cannot apply operator < to types DateTimeZone and DateTime.\r\n     inner exception: We cannot apply operator < to types DateTimeZone and DateTime.\r\nclientRequestId: e127bd54-0038-4c46-9a31-ce94547c226c",
    "error": {
      "message": "We cannot apply operator < to types DateTimeZone and DateTime.\r\n     inner exception: We cannot apply operator < to types DateTimeZone and DateTime."
    },
    "source": "sql-eus.azconn-eus-002.p.azurewebsites.net"
  },
  "responseType": "text"
}

Ah, Power Apps shows no error since it returned a 400 status, but the body contains the actual error: "We cannot apply operator < to types DateTimeZone and DateTime.\r\n     inner exception: We cannot apply operator < to types DateTimeZone and DateTime.\r\nclientRequestId: e927bd54-0038-4c46-9a31-ce94547c226c".  Apparently my DateTime column in SQL does not play well with Power App’s Date Time.  After some googling I found some community posts as well:


The Solution

The last community post above suggests that I should try the DateTimeOffset column type in SQL, and after another return to the googling I found a very similar issue described by Tim Leung, describing the same thing.  Unfortunately no one documented how to do this, so here I am, documenting how to do it for you dear reader, as well as future me !  Please be warned, I’m still not sure how DateTimeOffset plays with other tools/systems, so test first!)

  1. Update the DateTime Column in SQL Server
  2. ALTER TABLE dbo.<YourTableName>
    ALTER COLUMN <YourDateColumn> datetimeoffset(0) NOT NULL;

    UPDATE dbo.<YourTableName>
    SET <YourDateColumn> = CONVERT(datetime, <YourDateColumn>) AT TIME ZONE <YourTimeZone>;

    /*
    I don't believe there is a Daylight Saving Time option to timezones, but I just happened to be in EST, not EDT, so my last line looked like this:

        SET <YourDateColumn> = CONVERT(datetime, <YourDateColumn>) AT TIME ZONE 'Eastern Standard Time';

    Use SELECT * FROM Sys.time_zone_info to find your time zone.
    */

  3. Refresh the Data source in the app

  4. In Canvas Apps Studio, click data source options menu and select Refresh
  5. Reload the app
  6. I had problems with the Data Table control I was using not applying the timezone offset correctly.  Reloading the app seemed to fix this issue.

  7. Viola!


It’s not hard, but it definitely is a headache that I would hope Microsoft will solve.