MapsterMapper / Mapster

A fast, fun and stimulating object to object Mapper
MIT License
4.31k stars 328 forks source link

Mapping a class with only readonly properties throws an exception #410

Open ravenyue opened 2 years ago

ravenyue commented 2 years ago

Mapping a class with only readonly properties throws an exception


var studentDto = new StudentDto { Name = "abc" };
var student = studentDto.Adapt<Student>(); //Throw exception

Throw System.InvalidOperationException: Cannot convert immutable type, please consider using 'MapWith' method to create mapping

image

public class Student
{
    [UseDestinationValue]
    public string Name { get; }
}

public class StudentDto
{
    public string Name { get; set; }
}

Works fine when containing non-read-only properties

public class Student
{
    public int Id { get; set; }
    [UseDestinationValue]
    public string Name { get; }
}

public class StudentDto
{
    public int Id { get; set; }
    public string Name { get; set; }
}

Is this a feature or a bug, am I using it in the wrong way?

samuelcadieux commented 2 years ago

We're experiencing the same issue on our end. I've followed the execution and figured out why this is happening.

Essentially, why we're experiencing this is because the destination type fails this check on ClassMapper.CanMap

protected override bool CanMap(PreCompileArgument arg)
{
    return arg.ExplicitMapping || arg.DestinationType.IsPoco();
}

Since we don't have an ExplicitMapping defined for this convertion and IsPoco here is returning false here since none of the field satisfy this condition:

return type.GetFieldsAndProperties().Any(it => (it.SetterModifier & (AccessModifier.Public | AccessModifier.NonPublic)) != 0);

Then it falls back to using PrimitiveAdapter which will eventually fails.

The workaround here is to define an explicit mapping, see here for more details: https://github.com/MapsterMapper/Mapster/wiki/Config-validation-&-compilation#explicit-mapping

jeffward01 commented 1 year ago

We're experiencing the same issue on our end. I've followed the execution and figured out why this is happening.

Essentially, why we're experiencing this is because the destination type fails this check on ClassMapper.CanMap

protected override bool CanMap(PreCompileArgument arg)
{
    return arg.ExplicitMapping || arg.DestinationType.IsPoco();
}

Since we don't have an ExplicitMapping defined for this convertion and IsPoco here is returning false here since none of the field satisfy this condition:

return type.GetFieldsAndProperties().Any(it => (it.SetterModifier & (AccessModifier.Public | AccessModifier.NonPublic)) != 0);

Then it falls back to using PrimitiveAdapter which will eventually fails.

The workaround here is to define an explicit mapping, see here for more details: https://github.com/MapsterMapper/Mapster/wiki/Config-validation-&-compilation#explicit-mapping

Thanks for this, I will give it a try and report my results. My model is below:


public class BookDomain : Entity<Guid>
{
    private string _title;
    private string _subTitle;
    private DateTime _publishedDate;

    // Constructor
    public BookDomain() { }

    public string Title => this._title;
    public string SubTitle => this._subTitle;
    public DateTime PublishedDate => this._publishedDate;
}

public abstract partial class Entity<TKey> : IGeneratesDomainEvents, IEntity<TKey>, IEntity
    where TKey : IComparable<TKey>, IEquatable<TKey>
{
    private readonly ConcurrentQueue<IDomainEvent> _domainEvents = new();

    private DateTime _createdAt;

    private DateTime _deactivatedAt;

    private DateTime _deletedAt;

    private DateTime _modifiedAt;

    public abstract object[] GetKeys();

     public TKey? Id { get; }

    public bool IsActive { get; }

    public bool IsDeactivated { get; }

    public bool IsSoftDeleted { get; }

    public bool IsDeactivatedAndDeleted { get; }

    public bool HasPrimaryKey => this.IsTransient.IsFalse();

    public abstract bool IsTransient { get; }

    public DateTime? SoftDeletedAtUtc =>
        this._deletedAt.Equals(default) ? default : this._deletedAt;

    public DateTime CreatedAtUtc => this._createdAt.Equals(default) ? default : this._createdAt;

    public DateTime? DeactivatedAtUtc =>
        this._deactivatedAt.Equals(default) ? default : this._deactivatedAt;

    public DateTime ModifiedAtUtc => this._modifiedAt.Equals(default) ? default : this._modifiedAt;

    public TKey CreatedByUserId { get; }

    public TKey? DeletedByUserId { get; }

    public TKey? DeactivatedByUserId { get; }

    public TKey? ModifiedByUserId { get; }

    public void MarkCreate(TKey createdByUserId, DateTime? utcDateTime = null) { }

    public void MarkDeactivate(TKey deactivatedByUserId, DateTime? utcDateTime = null) { }

    public void MarkSoftDelete(TKey deletedByUserId, DateTime? utcDateTime = null) { }

    public void MarkModified(TKey modifiedByUserId, DateTime? utcDateTime = null) { }

    public void ClearDomainEvents()
    {
        this._domainEvents.Clear();
    }

    public int GetRaisedDomainEventCount()
    {
        return this._domainEvents.Count;
    }

    public IEnumerable<IDomainEvent> GetAllDomainEvents()
    {
        return this._domainEvents;
    }

    public async IAsyncEnumerable<IDomainEvent> GetAllDomainEventsAsync(
        [EnumeratorCancellation] CancellationToken cancellationToken = default
    )
    {
        await foreach (
            IDomainEvent domainEvent in this._domainEvents
                .ToAsyncEnumerable()
                .WithCancellation(cancellationToken)
        )
        {
            yield return domainEvent;
        }
    }

    protected internal virtual void RaiseDomainEvent(IDomainEvent domainEvent)
    {
        this._domainEvents.Enqueue(domainEvent);
    }
}

My mapping config:

    public static void RegisterMappers(IServiceCollection services)
    {
        services.AddSingleton(GetConfiguredMappingConfig());
        services.AddScoped<IMapper, ServiceMapper>();
    }

    private static TypeAdapterConfig GetConfiguredMappingConfig()
    {
        TypeAdapterConfig config = new();

        config.NewConfig<Book, BookDomain>()
            // My expectation was that invoking this method below would
            // result in .IsPoco() == true, however this is not the case
            .EnableNonPublicMembers(true);

        return config;
    }

@samuelcadieux - do you find it strange that .EnableNonPublicMembers(true); is not working?

See documentation here

The documentation is explicit that it is for Non-Public Members

The definition of Members from Microsoft can be found here

Screenshot: image

jeffward01 commented 1 year ago

Update ^^

I changed the class to use only private properties with getter and setter's and the issue persisted.

jeffward01 commented 1 year ago

The workaround here is to define an explicit mapping, see here for more details: https://github.com/MapsterMapper/Mapster/wiki/Config-validation-&-compilation#explicit-mapping

I changed my code back to the original (private fields) and had success with this:

When the destination class was 'fields' (properties with backer fields)


// Success
  TypeAdapterConfig<Book, BookDomain>.NewConfig()
            .Map("_id", "Id");

Obviously I have to map it for all of the values.

When the destination class was 'private properties with getter and setters'

// Success
TypeAdapterConfig<Book, BookDomain>.NewConfig()
            .EnableNonPublicMembers(true);