Examples

In this section we will add relevant examples for different business scenarios. We are aware that some of the examples are a bit teoretical, and there are other ways to solve the problems described. But the purpose of the example is to show how to solve a problem with code. The fact that there are other - maeby even better ways of solving - still allow the example to do a conceptual walk through of problems and solution.

one to one relationsship

Dynamics 365 CE supports 1:M and M:M relationsship out of box, but there is no standard support from 1:1 or 1:0-1 relationships. I assume the Dynamics team things this type of relations is not needed because you can just add more fields to the primary entity in such relation.
In most cases i agree, but there are situations where a 1:1 and 1:0-1 relations can come ind handy. Just to mention a couple of situations:

  • Some Dynamics 365 std entities cannot be added as references on other entities. Ex. CustomerAddress, QuoteDetail, OpportunityDetail, SalesorderDetail, InvoiceDetail just to mention a few. By createting a 1:1 mirror of such entity, you can create references on that instead.
  • Some Dynamics 365 std entities becomes immutable when state is changed to inactive. Having additional information that can be added after the record change to inactive might be convinient
  • Finally you might have situations where you have the primary entity, but rarely need a large tjunk of fields. In such situation a 1:0-1 relation might make sense to save space etc.

Creating a system maintained 1:1 or 1:0-1 relation can help you overcome the above problems.

the difference between 1:1 and 1:0-1 (where N is the 0-1) relationship is really wether you create the N record lazy or not. If you create it alongside the primary record, it becomes a 1:1, if you create it on a need base it becomes a 1:0-1. The design pattern used in the example is the same, but we will provide both examples.

Lets start with the 1:1 approach

But first we need a problem. Our customer is creating invoices in CRM and transfer these to a Customer payment management application (like Business Central). As part of this transfer mechanism we deaktivate the Invoice in CRM to easy prevent any user update of the invoice. The CRM standard will prevent any update of an invoice in inactive state, but we wish to be able to see in CRM if and when the invoice was payed. So whenever our payment management system receives a payment, we wish to update the payment date and payed amount to be visible in CRM.

To support the requirement, we create a new entity ko_invoicestatus, and on the invoice entity, we create a lookup field to this new entity: invoice.ko_invoicestatusid

From a technical requirement point of view, we need to implement the following business logic

  • On create of an invoice, the ko_invoicestatus record should be created automatically
  • On update of an invoice, "shared fields" between invoice and ko_invoicestatus should be updated. In our design we limit shared fields to:
    • invoice.Name maps to ko_invoicestatus.ko_name
    • invoice.OwnerId maps to ko_invoicestatus.OwnerId
  • On delete invoice, we need to delete the ko_invoicestatus record as well to avoid goasts
  • Finally we need to ensure that ko_invoicestatus is only created,updated and deleted within an invoice context, so you cannot use the UI to create inconsistency.

First lets define a service interface and related entity model interfaces to reflect the requirement


namespace Kipon.PluginExample.ServiceAPI
{
    public interface IInvoiceStatus1to1Service
    {
        void CreateStatus(Entities.Invoice target);
        void UpdateStatus(Entities.Invoice.IInvoiceStatusDataChanged target);
        void DeleteStatus(Entities.InvoiceReference target);
    }
}

This interface is defining hooks that can be called whenever an Invoice is created, updated or deleted. The parameter for Create is trivial, we simply ask for the entity payload of the invoice being created. The parameter for delete is trivial as well, we simply ask for the entity references of the Invoice being deleted. When it comes to the update operation, that is more specialized to reflect a kipon solid plugin style. It is an interface that describe what we are interested in, in regards to updates of an invoice (an invoice jummy jummy interface representing witch changes are relevant):


namespace Kipon.PluginExample.Entities
{
    public partial class Invoice : Invoice.IInvoiceStatusDataChanged
    {
        bool IInvoiceStatusDataChanged.NameChanged => this.Attributes.ContainsKey(nameof(Name).ToLower());

        public interface IInvoiceStatusDataChanged : IInvoiceTarget
        {
            string Name { get; }
            Microsoft.Xrm.Sdk.EntityReference OwnerId { get; }

            bool NameChanged { get; }
        }
    }
}

the interface defines two getter methods, one for Name and one for OwnerId. On top of that it extends the IInvoiceTarget interface. This is a marker interface generated by the Kipon solid tool that states that any implementation of this represents an Invoice Target in a dynamics 365 ce pipeline process.

You should also notise that the interface is created as an inner class for the Invoice entity class, and that we state that the Invoice entity class actually implements this interface. By letting the entity class implement our event description interface, we ensure that there is no misspelling of properties, and all properties we need to listen for are listed. In all other parts of the update pipeline event we will only parse around the interface, so there is no way we can access elements of the entity we did not ask for.

Finally the interface defines a NameChanged bool property. The reason for this is, that we will only mirror name from Invoice to ko_invoicestatus if it was actually changed, and we also have OwnerId, the interface represent the following events:

  • OwnerId was updated or assigned (only the OwnerId of the update payload will have a value)
  • Name was updated (only the Name of the update payload will have a value)
  • OwnerId and name was updated (both OwnerId and Name of the payload will have a value)
Then why don't we have a similar attribute "OwnerIdChanged". The reason is simple. Dynamics 365 CE do not allow assign null to OwnerId of a record. So if the value is null, it was not assigned by the client code. But Entities.Invoice does not have a NameChanged property as part of the crmsvcutil generated entity class, so we take advantage of the fact that our interface is and inner thing of the entity class, and makes an explicit implementation directly wihin the definition. the interface is simply looking for a value of the Name in the Entities Attributes list, and returns true if it is there. Remember all keys of Attributes of an entity i lowercase.

Now we have the service infrastructure, so we can create the plugin:


namespace Kipon.PluginExample.Plugins.Invoice
{
    public class Invoiceone2onePlugin : Kipon.Xrm.BasePlugin
    {
        public void OnPreCreate(Entities.Invoice target, ServiceAPI.IInvoiceStatus1to1Service service)
        {
            service.CreateStatus(target);
        }

        public void OnPreUpdate(Entities.Invoice.IInvoiceStatusDataChanged target, ServiceAPI.IInvoiceStatus1to1Service service)
        {
            service.UpdateStatus(target);
        }

        public void OnPreDelete(Entities.InvoiceReference target, ServiceAPI.IInvoiceStatus1to1Service service)
        {
            service.DeleteStatus(target);
        }
    }
}

The plugin is simply hooking into the three events OnPreCreate, OnPreUpdate, OnPreDelete of an invoice, and on each event it delegates the event to the IInvoiceStatus1to1Service

Because the OnPreUpdate event is taking the IInvoiceStatusDataChanged as target parameter, this method will only be called if ether name or ownerid or both was changed. This definition drains all the way down to the plugin definition infrastructure by added targetfilter to the plugin registration done by the kipon solid plugin tool.

But to make the code work, we need an implementation of IInvoiceStatus1to1Service


using Kipon.PluginExample.Entities;
using Kipon.Xrm.Attributes;
using Kipon.Xrm;

namespace Kipon.PluginExample.Services
{
    public class InvoiceStatus1to1Service : ServiceAPI.IInvoiceStatus1to1Service
    {
        private readonly IRepository<ko_invoicestatus> invoiceStatusRepro;

        public InvoiceStatus1to1Service(
            [Admin]IRepository<Entities.ko_invoicestatus> invoiceStatusRepro)
        {
            this.invoiceStatusRepro = invoiceStatusRepro;
        }

        public void CreateStatus(Invoice target)
        {
            this.invoiceStatusRepro.Add(
                new ko_invoicestatus 
                { 
                    ko_invoicestatusId = target.InvoiceId.Value, 
                    OwnerId = target.OwnerId, 
                    ko_name = target.Name 
                }
            );

            target.ko_invoicestatusid = new Microsoft.Xrm.Sdk.EntityReference(Entities.ko_invoicestatus.EntityLogicalName, target.InvoiceId.Value);
        }

        public void UpdateStatus(Invoice.IInvoiceStatusDataChanged target)
        {
            var clean = new Entities.ko_invoicestatus { ko_invoicestatusId = target.Id };
            if (target.NameChanged)
            {
                clean.ko_name = target.Name;
            }

            if (target.OwnerId != null)
            {
                clean.OwnerId = target.OwnerId;
            }

            invoiceStatusRepro.Update(clean);
        }

        public void DeleteStatus(InvoiceReference target)
        {
            this.invoiceStatusRepro.Delete(
                new ko_invoicestatus { ko_invoicestatusId = target.Value.Id }
                );
        }
    }
}

Lets look at the implementation, starting with the constructor. We inject a IRepository<ko_invoicestatus> into the service, because we need functionality to create, update and delete this type of records in the servce. Be aware of the [Admin] tag infront of the constructor parameter definition. This is telling the platform that the repository should have systemadmin priviliges when it runs. The advantage of this is, that you only need to assign you users read access to the ko_invoicestatus record. They will still be able to create invoices (if they have access), and the ko_invoicestatus record will be created even though they do not have create priviliges on that entity. The most easy way to remove create/delete buttons on an entity in the UI is to not grant the user such privilige.

CreateStatus: We simply create an instance of the ko_invoicestatus record. Remark, that we assign the ko_invoicestatus record the same ID as the invoice. This is perfectly possible and allowed, and because it is a 1:1 relation (there can be only one), it is a natural thing to do. After creating the ko_invoicestatus instance, we assign the invoice.ko_invoicestatusid field to the newly created status record, so the invoice has a formal references to its status record (this will allow you to create views and quickforms that shows properties of the invoicestatus within an invoice view or form).

UpdateStatus: First updatestatus is creating an instance of the ko_invoicestatus with an empty payload. (no properties beside the key is set). Because ko_invoicestatus is created from invoices on create, we know they share id, so we can simply take the key from the invoice. Then we assign the ko_name and OwnerId of the ko_invoicestatus record if applicable, and finally we push the update to the repository.

DeleteStatus: Finally, on delete of an invoice, we delete the correcspondig record of ko_invoicestatus.

Finally we need to ensure that our new ko_invoicestatus record is only created as a child record of an invoice, that we should not be able to delete this record without deleting the invoice first. For update, name and ownership must be updated from the invoice, not directly.


using Kipon.Xrm.Extensions.Sdk;
using Microsoft.Xrm.Sdk;

namespace Kipon.PluginExample.Plugins.ko_invoicestatus
{
    public class ko_invoicestatusPlugin : Kipon.Xrm.BasePlugin
    {
        public void OnValidateCreate(Entities.ko_invoicestatus target, IPluginExecutionContext ctx)
        {
            if (!ctx.IsChildOf("Create", Entities.Invoice.EntityLogicalName, target.ko_invoicestatusId.Value))
            {
                throw new InvalidPluginExecutionException("ko_invoicestatus can only be created in invoice create child process");
            }
        }

        public void OnValidateUpdate(Entities.ko_invoicestatus.IInvoiceDataChanged target, IPluginExecutionContext ctx)
        {
            if (!ctx.IsChildOf("Update", Entities.Invoice.EntityLogicalName, target.Id) && !ctx.IsChildOf("Assign", Entities.Invoice.EntityLogicalName, target.Id))
            {
                throw new InvalidPluginExecutionException("ko_invoicestatus name and owner can only be updated in invoice assign or update child process");
            }
        }

        public void OnPreDelete(Entities.ko_invoicestatusReference target, IPluginExecutionContext ctx)
        {
            if (!ctx.IsChildOf("Delete", Entities.Invoice.EntityLogicalName, target.Value.Id))
            {
                throw new InvalidPluginExecutionException("ko_invoicestatus can only be deleted in a invoice delete child process");
            }
        }
    }
}

You should remark the using Kipon.Xrm.Extensions.Sdk namespace and the injection of the Microsoft.Xrm.Sdk.IPluginExecutionContext. The later is one of the primary infrastructure services of the plugin pipeline. Most of the Kipon.Solid.Plugin framework is about wire that cat to a more natural programming style. The Kipon solid framework is adding some extension methods to that cat, amoung these the "IsChildOf" method. That method will look down in the pipeline process and see if it can match a parent process with the method, entitylogicalname and id according to the parameters. So when we say !ctx.IschildOf(..) what we accomplish is to thown and exception, if the current operation is NOT child of the other. In other words, if a ko_invoicestatus is created directly, and not in the context of a invoice create, and exception will be thrown preventing the operation.

For delete it is same song, except that we listen OnPreDelete. The reason for choosing OnPreDelete instead of OnValidateDelete is that OnValidateDelete is not called when a delete operation is triggered from a cascade delete from its parent. In this case that is actually not a problem, but because i have been hit by that rule in other situations, i never prevents delete in OnValidateDelete stage, i always wait to the OnPreDelete is happening to be sure i am called in all situations.

Finally OnValidateUpdate is taking a ko_invoicestatus jummy jummy interface that represents the fields that are mirrored from the invoice. If any of these two fields is changed, it must be because the invoice it self is being changed:


namespace Kipon.PluginExample.Entities
{
    public partial class ko_invoicestatus : ko_invoicestatus.IInvoiceDataChanged
    {
        public interface IInvoiceDataChanged : Iko_invoicestatusTarget
        {
            string ko_name { get; }
            Microsoft.Xrm.Sdk.EntityReference OwnerId { get; }
        }
    }
}

Finally we should create unittests

using Microsoft.VisualStudio.TestTools.UnitTesting;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;

namespace Kipon.PluginExample.UT.Plugins.Invoice
{
    [TestClass]
    public class InvoiceStatusPluginTest
    {
        [TestMethod]
        public void OnInvoiceCreateTest()
        {
            using (var ctx = Kipon.Xrm.Fake.Repository.PluginExecutionFakeContext.ForType<Kipon.PluginExample.Plugins.Invoice.Invoiceone2onePlugin>())
            {
                var user = new Kipon.PluginExample.Entities.SystemUser { SystemUserId = Guid.NewGuid(), FirstName = "Kjeld", LastName = "Poulsen" };
                ctx.AddEntity(user);

                var newinvoice = new Kipon.PluginExample.Entities.Invoice 
                { 
                    InvoiceId = Guid.NewGuid(), 
                    Name = "Test invoice", 
                    OwnerId = user.ToEntityReference() 
                };

                ctx.OnPre = delegate
                {
                    var status = ctx.GetEntityById<Kipon.PluginExample.Entities.ko_invoicestatus>(newinvoice.InvoiceId.Value);

                    Assert.AreEqual(newinvoice.Name, status.ko_name);
                    Assert.AreEqual(newinvoice.OwnerId.Id, newinvoice.OwnerId.Id);

                    Assert.AreEqual(newinvoice.ko_invoicestatusid.Id, status.ko_invoicestatusId.Value);
                };

                ctx.Create(newinvoice);
            }
        }

        [TestMethod]
        public void OnInvoiceUpdateTest()
        {
            using (var ctx = Kipon.Xrm.Fake.Repository.PluginExecutionFakeContext.ForType<Kipon.PluginExample.Plugins.Invoice.Invoiceone2onePlugin>())
            {
                #region prepare context
                var currentUser = new Kipon.PluginExample.Entities.SystemUser { SystemUserId = Guid.NewGuid(), FirstName = "Kjeld", LastName = "Poulsen" };
                ctx.AddEntity(currentUser);

                var nextOwner = new Kipon.PluginExample.Entities.SystemUser { SystemUserId = Guid.NewGuid(), FirstName = "Peter", LastName = "Jensen" };
                ctx.AddEntity(nextOwner);

                var invoiceId = Guid.NewGuid();
                ctx.AddEntity(new Kipon.PluginExample.Entities.Invoice 
                { 
                    InvoiceId = invoiceId, 
                    Name = "Test invoice", 
                    OwnerId = currentUser.ToEntityReference(), 
                    ko_invoicestatusid = new Microsoft.Xrm.Sdk.EntityReference(Entities.ko_invoicestatus.EntityLogicalName, invoiceId)
                });

                ctx.AddEntity(new Kipon.PluginExample.Entities.ko_invoicestatus
                {
                    ko_invoicestatusId = invoiceId,
                    ko_name = "Test invoice",
                    OwnerId = currentUser.ToEntityReference()
                });
                #endregion

                ctx.OnPre = delegate
                {
                    var invoice = ctx.GetEntityById<Entities.Invoice>(invoiceId);
                    var status = ctx.GetEntityById<Entities.ko_invoicestatus>(invoiceId);

                    Assert.AreEqual(invoice.Name, status.ko_name);
                    Assert.AreEqual(invoice.OwnerId.Id, invoice.OwnerId.Id);

                    Assert.AreEqual(invoice.Name, "Name changed to this");
                    Assert.AreEqual(invoice.OwnerId.Id, nextOwner.SystemUserId.Value);
                };

                ctx.Update(new Entities.Invoice 
                { 
                    InvoiceId = invoiceId,
                    Name = "Name changed to this",
                    OwnerId = nextOwner.ToEntityReference()
                });
            }
        }

        [TestMethod]
        public void OnInvoiceDeleteTest()
        {
            using (var ctx = Kipon.Xrm.Fake.Repository.PluginExecutionFakeContext.ForType<Kipon.PluginExample.Plugins.Invoice.Invoiceone2onePlugin>())
            {
                #region prepare context
                var currentUser = new Kipon.PluginExample.Entities.SystemUser { SystemUserId = Guid.NewGuid(), FirstName = "Kjeld", LastName = "Poulsen" };
                ctx.AddEntity(currentUser);

                var invoiceId = Guid.NewGuid();
                ctx.AddEntity(new Kipon.PluginExample.Entities.Invoice
                {
                    InvoiceId = invoiceId,
                    Name = "Test invoice",
                    OwnerId = currentUser.ToEntityReference(),
                    ko_invoicestatusid = new Microsoft.Xrm.Sdk.EntityReference(Entities.ko_invoicestatus.EntityLogicalName, invoiceId)
                });

                ctx.AddEntity(new Kipon.PluginExample.Entities.ko_invoicestatus
                {
                    ko_invoicestatusId = invoiceId,
                    ko_name = "Test invoice",
                    OwnerId = currentUser.ToEntityReference()
                });
                #endregion

                Assert.IsNotNull(ctx.GetQuery<Entities.ko_invoicestatus>().Where(r => r.ko_invoicestatusId == invoiceId).SingleOrDefault());

                ctx.OnPre = delegate
                {
                    Assert.IsNull(ctx.GetQuery<Entities.ko_invoicestatus>().Where(r => r.ko_invoicestatusId == invoiceId).SingleOrDefault());
                };

                ctx.Delete(new Microsoft.Xrm.Sdk.EntityReference(Entities.Invoice.EntityLogicalName, invoiceId));
            }
        }
    }
}

OnInvoiceCreateTest, we create a fake context for the plugin type Invoiceone2onePlugin, then we prepare the context with data about the systemuser to be used as owner. Then we create a payload instance for invoice create, and finally we call the create method on the context with that invoice.
In the OnPre delegate we verify that we now have an entity instance of type ko_invoicestatus with same id in the context, and name and ownerid has been assigned according to the invoice created.
OnInvoiceUpdateTest require i bit more context preparation, because it is an update of an invoice, so we need to add the invoice and the ko_invoicestatus to the ctx as is, before we trigger an update.
In the OnPre event delegate we verify that the changes to invoice has drained into the ko_invoicestatus as well.
OnInvoiceDeleteTest require the same level of ctx preparation as OnInvoiceUpdateTest, and the OnPre delegate verifies that when deleting the Invoice, the ko_invoicestatus will be deleted as well.

© Kipon ApS 2020, 2021, 2022, 2023, 2024. All content on the page is the property of Kipon ApS. Any republish or copy of this content is a violation. The content of this site is NOT open source, and cannot be copied, republished or used in any context without explcit permission from the owner.