Skip to content
This repository has been archived by the owner on Jun 16, 2024. It is now read-only.

Name property not set on EntityReference attribute of retrieved record #555

Open
janssen-io opened this issue May 5, 2021 · 18 comments
Open

Comments

@janssen-io
Copy link

janssen-io commented May 5, 2021

Describe the bug
When retrieving a record with an EntityReference column, the Name property is not set on the attribute.

To Reproduce

[Fact]
public void RetrieveEntityReferenceSetsName()
{
    // Arrange
    var account = new Account { Id = Guid.NewGuid() };
    var contact = new Contact
    {
        Id = Guid.NewGuid(),
        ["fullname"] = "Jan de Vries",
    };

    account.PrimaryContactId = contact.ToEntityReference();

    var context = new XrmFakedContext();
    context.Initialize(new Entity[] { account, contact });

    // Act
    var accountWithContact = context.GetOrganizationService()
        .Retrieve(account.LogicalName, account.Id, new ColumnSet(Account.Fields.PrimaryContactId))
        .ToEntity<Account>();

    // Assert
    Assert.Equal("Jan de Vries", contact.FullName);
    Assert.NotNull(accountWithContact.PrimaryContactId.Name); // <-- This assertion fails :(
    Assert.Equal(contact.FullName, accountWithContact.PrimaryContactId.Name);
}

Expected behavior
The Name property on the retrieved account (accountWithContact) should be filled with the value of the PrimaryNameAttribute (FullName). This is the case on the server (latest Dynamics 365), but not with FakeXrmEasy 1.57.1

FakeXrmEasy and Dynamics 365 / CRM version
Dynamics 365 (latest release)
FakeXrmEasy 1.57.1

Screenshots
N/A

Update:
For future readers: the problem is that the official tooling (CrmSvcUtil v9) does not generate the required metadata. Therefore the current version of FakeXrmEasy does also not read it. I expected it to be read as I use a templated early bound generator that does create a field with this metadata.

@janssen-io janssen-io added the bug label May 5, 2021
@BetimBeja
Copy link
Contributor

Hello @janssen-io,
try initializing the metadata for Account and Contact and check if you have the same problem.

One example where this is working can be found here

public static void Should_Populate_EntityReference_Name_When_Metadata_Is_Provided()
{
var userMetadata = new EntityMetadata() { LogicalName = "systemuser" };
userMetadata.SetSealedPropertyValue("PrimaryNameAttribute", "fullname");
var user = new Entity() { LogicalName = "systemuser", Id=Guid.NewGuid() };
user["fullname"] = "Fake XrmEasy";
var context = new XrmFakedContext();
context.InitializeMetadata(userMetadata);
context.Initialize(user);
context.CallerId = user.ToEntityReference();
var account = new Entity() { LogicalName = "account" };
var service = context.GetOrganizationService();
var accountId = service.Create(account);
account = service.Retrieve("account", accountId, new ColumnSet(true));
Assert.Equal("Fake XrmEasy", account.GetAttributeValue<EntityReference>("ownerid").Name);
}

@jordimontana82
Copy link
Owner

@janssen-io as @BetimBeja suggested above, the Name property requires EntityMetadata to be set before it can be returned.

@janssen-io
Copy link
Author

janssen-io commented May 5, 2021

Thank you for the quick response!

I've tried setting the metadata before but both these attempts failed to produce the desired results.
The early bound entities (as generated by CrmSvcUtil) also contain the string PrimaryNameAttribute field.

Method 1:

        [Fact]
        public void RetrieveEntityReferenceSetsName()
        {
            // ...
            var context = new XrmFakedContext();

            var contactMetadata = new EntityMetadata() { LogicalName = "contact" };
            contactMetadata.SetSealedPropertyValue("PrimaryNameAttribute", "fullname");
            context.InitializeMetadata(contactMetadata);

            context.Initialize(new Entity[] { account, contact });
            // ...
        }

Method 2:

        [Fact]
        public void RetrieveEntityReferenceSetsName()
        {
            // ...
            var context = new XrmFakedContext();

            context.InitializeMetadata(Assembly.GetAssembly(typeof(Contact)));

            context.Initialize(new Entity[] { account, contact });
            // ...
        }

Edit:
Oddly enough your example does work. So I wonder what makes the difference here.

Edit 2:
Just noticed a typo in the first example I posted. Testing it again.

Seems like method 1 works. :) Too bad method 2 doesn't seem to work for me. Maybe there is something wrong with the early bound entities.

Again, thanks for the reply!

@jordimontana82
Copy link
Owner

jordimontana82 commented May 5, 2021

Method 2 was an approximation of Method 1 to save you from having to manually inject metadata, but it won't fit all scenarios. Maybe CrmSvcUtil generates the attribute as you said but it doesn't autopopulate it in the getter method.... so FakeXrmEasy wouldn't know which field to use. Suggest using method 1 for this scenario.

@jordimontana82 jordimontana82 removed the bug label May 5, 2021
@jordimontana82
Copy link
Owner

Closing this one. If you know what caused issue with Method 2 would be good to know though @janssen-io

@janssen-io
Copy link
Author

janssen-io commented May 6, 2021

I checked if it even created EntityMetadata and I'm happy to report it does.
But it does not set the PrimaryNameAttribute.

Looking at the method that generates the metadata (MetadataGenerator#L14), I also can't figure out how or even if it gets set via this method.

Could it be that the PrimaryNameAttribute does not get set by reading the Early Bound types?

I'd be happy to whip up a PR if this is something that should be implemented. My approach would be to look at the generated field public const string PrimaryNameAttribute. As that is how my version of crmsvcutil generates it. As that is how Daryl LaBar's Early Bound Generator creates it.

Edit: Ah, I see this is not the case for the Early Bounds in the test project of FakeXrmEasy. So probably not the best candidate then...

Example of an early bound entity using this tool:

	[System.Runtime.Serialization.DataContractAttribute()]
	[Microsoft.Xrm.Sdk.Client.EntityLogicalNameAttribute("systemuser")]
	public partial class SystemUser : Microsoft.Xrm.Sdk.Entity, System.ComponentModel.INotifyPropertyChanging, System.ComponentModel.INotifyPropertyChanged
	{
		
		public static class Fields
		{
			public const string AccessMode = "accessmode";
			// etc.
		}
		
		/// <summary>
		/// Default Constructor.
		/// </summary>
		[System.Diagnostics.DebuggerNonUserCode()]
		public SystemUser() : 
				base(EntityLogicalName)
		{
		}
		
		public const string AlternateKeys = "azureactivedirectoryobjectid";
		public const string EntityLogicalName = "systemuser";
		public const string EntitySchemaName = "SystemUser";
		public const string PrimaryIdAttribute = "systemuserid";
		public const string PrimaryNameAttribute = "fullname";
		public const string EntityLogicalCollectionName = "systemusers";
		public const string EntitySetName = "systemusers";
		public event System.ComponentModel.PropertyChangedEventHandler PropertyChanged;
		public event System.ComponentModel.PropertyChangingEventHandler PropertyChanging;
		
		[System.Diagnostics.DebuggerNonUserCode()]
		private void OnPropertyChanged(string propertyName)
		{
			if ((this.PropertyChanged != null))
			{
				this.PropertyChanged(this, new System.ComponentModel.PropertyChangedEventArgs(propertyName));
			}
		}
		
		[System.Diagnostics.DebuggerNonUserCode()]
		private void OnPropertyChanging(string propertyName)
		{
			if ((this.PropertyChanging != null))
			{
				this.PropertyChanging(this, new System.ComponentModel.PropertyChangingEventArgs(propertyName));
			}
		}
		
		/// <summary>
		/// Type of user.
		/// </summary>
		[Microsoft.Xrm.Sdk.AttributeLogicalNameAttribute("accessmode")]
		public virtual SystemUser_AccessMode? AccessMode
		{
			[System.Diagnostics.DebuggerNonUserCode()]
			get
			{
				return ((SystemUser_AccessMode?)(EntityOptionSetEnum.GetEnum(this, "accessmode")));
			}
			[System.Diagnostics.DebuggerNonUserCode()]
			set
			{
				this.OnPropertyChanging("AccessMode");
				this.SetAttributeValue("accessmode", value.HasValue ? new Microsoft.Xrm.Sdk.OptionSetValue((int)value) : null);
				this.OnPropertyChanged("AccessMode");
			}
		}
		
		// etc.
		
		/// <summary>
		/// Full name of the user.
		/// </summary>
		[Microsoft.Xrm.Sdk.AttributeLogicalNameAttribute("fullname")]
		public string FullName
		{
			[System.Diagnostics.DebuggerNonUserCode()]
			get
			{
				return this.GetAttributeValue<string>("fullname");
			}
		}

               // etc.
		
		/// <summary>
		/// 1:N contact_owning_user
		/// </summary>
		[Microsoft.Xrm.Sdk.RelationshipSchemaNameAttribute("contact_owning_user")]
		public System.Collections.Generic.IEnumerable<Crm.Shared.ContractRouting.Contact> contact_owning_user
		{
			[System.Diagnostics.DebuggerNonUserCode()]
			get
			{
				return this.GetRelatedEntities<Crm.Shared.ContractRouting.Contact>("contact_owning_user", null);
			}
			[System.Diagnostics.DebuggerNonUserCode()]
			set
			{
				this.OnPropertyChanging("contact_owning_user");
				this.SetRelatedEntities<Crm.Shared.ContractRouting.Contact>("contact_owning_user", null, value);
				this.OnPropertyChanged("contact_owning_user");
			}
		}
		
		/// <summary>
		/// N:N systemuserroles_association
		/// </summary>
		[Microsoft.Xrm.Sdk.RelationshipSchemaNameAttribute("systemuserroles_association")]
		public System.Collections.Generic.IEnumerable<Crm.Shared.ContractRouting.Role> systemuserroles_association
		{
			[System.Diagnostics.DebuggerNonUserCode()]
			get
			{
				return this.GetRelatedEntities<Crm.Shared.ContractRouting.Role>("systemuserroles_association", null);
			}
			[System.Diagnostics.DebuggerNonUserCode()]
			set
			{
				this.OnPropertyChanging("systemuserroles_association");
				this.SetRelatedEntities<Crm.Shared.ContractRouting.Role>("systemuserroles_association", null, value);
				this.OnPropertyChanged("systemuserroles_association");
			}
		}
		
		/// <summary>
		/// N:1 lk_systemuser_createdonbehalfby
		/// </summary>
		[Microsoft.Xrm.Sdk.AttributeLogicalNameAttribute("createdonbehalfby")]
		[Microsoft.Xrm.Sdk.RelationshipSchemaNameAttribute("lk_systemuser_createdonbehalfby", Microsoft.Xrm.Sdk.EntityRole.Referencing)]
		public Crm.Shared.ContractRouting.SystemUser Referencinglk_systemuser_createdonbehalfby
		{
			[System.Diagnostics.DebuggerNonUserCode()]
			get
			{
				return this.GetRelatedEntity<Crm.Shared.ContractRouting.SystemUser>("lk_systemuser_createdonbehalfby", Microsoft.Xrm.Sdk.EntityRole.Referencing);
			}
			[System.Diagnostics.DebuggerNonUserCode()]
			set
			{
				this.OnPropertyChanging("Referencinglk_systemuser_createdonbehalfby");
				this.SetRelatedEntity<Crm.Shared.ContractRouting.SystemUser>("lk_systemuser_createdonbehalfby", Microsoft.Xrm.Sdk.EntityRole.Referencing, value);
				this.OnPropertyChanged("Referencinglk_systemuser_createdonbehalfby");
			}
		}
		
		/// <summary>
		/// Constructor for populating via LINQ queries given a LINQ anonymous type
		/// <param name="anonymousType">LINQ anonymous type.</param>
		/// </summary>
		[System.Diagnostics.DebuggerNonUserCode()]
		public SystemUser(object anonymousType) : 
				this()
		{
            foreach (var p in anonymousType.GetType().GetProperties())
            {
                var value = p.GetValue(anonymousType, null);
                var name = p.Name.ToLower();
            
                if (name.EndsWith("enum") && value.GetType().BaseType == typeof(System.Enum))
                {
                    value = new Microsoft.Xrm.Sdk.OptionSetValue((int) value);
                    name = name.Remove(name.Length - "enum".Length);
                }
            
                switch (name)
                {
                    case "id":
                        base.Id = (System.Guid)value;
                        Attributes["systemuserid"] = base.Id;
                        break;
                    case "systemuserid":
                        var id = (System.Nullable<System.Guid>) value;
                        if(id == null){ continue; }
                        base.Id = id.Value;
                        Attributes[name] = base.Id;
                        break;
                    case "formattedvalues":
                        // Add Support for FormattedValues
                        FormattedValues.AddRange((Microsoft.Xrm.Sdk.FormattedValueCollection)value);
                        break;
                    default:
                        Attributes[name] = value;
                        break;
                }
            }
		}
	}

@jordimontana82
Copy link
Owner

jordimontana82 commented May 6, 2021

Thanks @janssen-io . What version of CrmSvcUtil are you using?

Wondering if it's a feature in one of the latest versions as previous ones didn't generate it. If so, it could be added to the method that generates metadata from early bound by using reflection and checking if that attribute exists, and so, use it.

The current sample early bound entities in this repo that were generated using v7 doesn't have that attribute for instance.

@janssen-io
Copy link
Author

janssen-io commented May 7, 2021

I downloaded the latest version of CrmSvcUtil (v9.1.0.82) and unfortunately it's still not included. Daryl's generator tool uses custom code generators to accomplish this.

On the one hand, the way they add the field is consistent with how other metadata is shown in the generated classes. So it would be nice if FakeXrmEasy could support it.
On the other hand, I can understand if you prefer to stick to the output of official Microsoft tools and not adjust to any particular output of 3rd party tools.

@jordimontana82
Copy link
Owner

jordimontana82 commented May 7, 2021

Ok, I suppose it would be nice to have a separate method similar to Method 2 with maybe a different name, that does this (i.e. like

InitializeMetadataFromEarlyBoundGenerator(Assembly assembly)

or maybe with same name but in a different namespace (i.e. FakeXrmEasy.Metadata.Extensions.EarlyBoundGenerator), to make it obvious the 2 methods do slightly different things.

@BetimBeja
Copy link
Contributor

@jordimontana82 there is this PR #447 which is EarlyBoundGenerator flavored too 😉

@BetimBeja
Copy link
Contributor

Since reading a constant value from a class needs to be done using reflection, I propose we use a configuration on what those fields are called. This way the implementation can be generic and anyone can use the EarlyBound flavor they like.

@janssen-io
Copy link
Author

janssen-io commented May 7, 2021

That sounds like a good way to go.

Alternatively we could provide an interface that can be implemented that sets additional metadata by reference on the EntityMetadata created in the MetadataGenerator. That way we don't have to deal with complex configurations and just delegate that responsibility to the client. For common or popular generators we could also provide this interface as part of FakeXrmEasy.

Something along the lines of:

public static IEnumerable<EntityMetadata> FromEarlyBoundEntities(Assembly earlyBoundEntitiesAssembly, IGenerateMetadata additionalMetadataGenerator = null)
{
    List<EntityMetadata> entityMetadatas = new List<EntityMetadata>();
    foreach (var earlyBoundEntity in earlyBoundEntitiesAssembly.GetTypes())
    {
        EntityLogicalNameAttribute entityLogicalNameAttribute = GetCustomAttribute<EntityLogicalNameAttribute>(earlyBoundEntity);
        if (entityLogicalNameAttribute == null) continue;

        EntityMetadata metadata = new EntityMetadata();
        // [...] all the current generator code
        if (additionalMetadataGenerator != null)
        {
            additionalMetadataGenerator.SetMetadata(metadata, earlyBoundEntity);
        }
        entityMetadatas.Add(metadata);
    }
    return entityMetadatas;
}

@jordimontana82
Copy link
Owner

@BetimBeja I forgot there was such PR , too many PRs 😄

I'd like to keep the 2 methods separate for different reasons:

  • They depend on two different assemblies that might evolve separately and could introduce regressions from one another.
  • For devs, it would avoid confusions like this issue: the root cause was in the use of Early Bound, I was originally confused if this was a CrmSvcUtil behavior or not at the begining 😅
  • FakeXrmEasy is changing from making assumptions to being more explicit in v2. Previously one created an instance of XrmFakedContext with everything on it and started overriding tons of stuff to fit whatever you like, but in v2, there will be a configurable middleware to build a context from lightweight to even pipeline simulation , but it'll be explicit, and possibly less confusing. I.e. You'll use Pipeline Simulation only if you really need to use PipelineSimulation and by telling so in the middleware setup.

@janssen-io
Copy link
Author

With either of our described approaches, we could introduce them as separate methods. Does either of them seem to fit with the new strategy? Or should we go an alternative route altogether?

@jordimontana82
Copy link
Owner

Having separate methods would fit current v1 and next v2 strategy yes.

@janssen-io
Copy link
Author

janssen-io commented May 7, 2021

While I'm working on a PR, I think just exposing the metadata generator would help a great deal in allowing custom metadata generators.

Then one could build their own metadata generator with the newly exposed CrmSvcUtilGenerator as a base implementation and inject the Metadatas using existing methods.

Example:

// In a developer's own project, they would define this:
public class CustomGenerator()
{
    public List<EntityMetadata> GenerateMetadata(Assembly earlyBoundAssembly) 
    {
        // Newly exposed metadata generator that updates the EntityMetadata reference for a single entity Type.
        var crmSvcUtilMetaGen = new FakeXrmEasy.Metadata.CrmSvcUtilMetadataGenerator();

        List<EntityMetadata> entityMetadatas = new List<EntityMetadata>();
        foreach (Type earlyBoundEntity in earlyBoundEntitiesAssembly.GetTypes())
        {
            EntityLogicalNameAttribute entityLogicalNameAttribute = earlyBoundEntity.GetCustomAttribute<EntityLogicalNameAttribute>();
            if (entityLogicalNameAttribute == null) continue;

            EntityMetadata metadata = new EntityMetadata();

            crmSvcUtilMetaGen.SetMetadata(metadata, earlyBoundEntity, entityLogicalNameAttribute);
            
            // The custom stuff we want to add
            SetPrimaryNameAttribute(metadata, earlyBoundEntity);

            entityMetadatas.Add(metadata);
        }
    }

    private void SetPrimaryNameAttribute(EntityMetadata metadata, Type earlyBoundEntity) 
    {
        FieldInfo primaryNameAttribute = earlyBoundEntity.GetField("PrimaryNameAttribute", BindingFlags.Static | BindingFlags.Public);
        if (primaryNameAttribute != null)
        {
            metadata.SetFieldValue("_primaryNameAttribute", primaryNameAttribute.GetValue(null));
        }
    }
}

// And then use it like this:
var metadata = new CustomGenerator().GenerateMetadata(Assembly.GetAssembly(typeof(Contact)));
var context = new XrmFakedContext();
context.InitializeMetadata(metadata);

Not sure if this exposes too much of the internals though. What do you think?

Edit: considering this change would be small, I'll make a PR to make it clearer.

@jordimontana82
Copy link
Owner

I like it

@jordimontana82
Copy link
Owner

Thoughts @BetimBeja @bwmodular ?

Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.
Projects
None yet
Development

No branches or pull requests

3 participants