Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Map constant value to properties and constructor parameters #631

Closed
cremor opened this issue Aug 7, 2023 · 13 comments · Fixed by #1335
Closed

Map constant value to properties and constructor parameters #631

cremor opened this issue Aug 7, 2023 · 13 comments · Fixed by #1335
Assignees
Labels
enhancement New feature or request

Comments

@cremor
Copy link

cremor commented Aug 7, 2023

Is your feature request related to a problem? Please describe.
If I have a target type that has a required property (or constructor parameter) and the source type does not have a matching property then I'm currently not able to use Mapperly to create the target object.

The only way to map such types right now is to use an object factory. But that results in ugly code because you need to initialize all required properties (or constructor parameters) in the object factory - even those that could be mapped from the source object. So this object factory might have many Xyz = default or (even worse) Xyz = default! assignments (or default/default! constructor parameter values).

Describe the solution you'd like
A new attribute like [MapperSetDefaultValue(nameof(SomeEntity.Id))] that would instruct the source generator to assign default to the property (or constructor parameter) given in that attribute.

Describe alternatives you've considered
A simpler solution that doesn't need a new attribute might be possible: The existing MapperIgnoreTargetAttribute could cause the source generator to assign default if it is a required property (or constructor parameter).

A more complex but also more customizable way to do this would be a property like [MapperSetValue(nameof(SomeEntity.Id), 0)], so one where even non-default values could be supplied.

Additional context
I'm not sure how nullable warnings are handled in generated code, but if the target property is not nullable then the source generator might have to assign default!. I'm not sure if this is a good idea. Maybe only allow this with an additional opt-in?

If one of the suggested new attributes is implemented: There could be a situation where a user applies the new attribute although a matching source property is available. Maybe it would be a good idea to show an analyzer warning (or even an error) in that case, unless the MapperIgnoreSourceAttribute is used.

Related discussion: #335

@latonz latonz added the enhancement New feature or request label Aug 8, 2023
@latonz
Copy link
Contributor

latonz commented Aug 8, 2023

To match other attributes I'd name it MapValueAttribute with the following signatures: [MapValue(string targetPropertyPath, object? value)] and [MapValue(string[] targetPropertyPath, object? value)]. This would allow to set constructor parameters as well as properties and nested properties to constant values. The constant value may need to be mapped/converted to the target type.

It should apply to target properties and constructor parameters but it should not affect constructor resolution.
If the target property cannot be found a diagnostic of type error should be emitted (probably RMG005 could be reused).
If an explicit mapping configuration exists for the same target property, an error diagnostic should be emitted.
MapValue should take precedence over MapProperty and unconfigured automatically mapped properties.

@latonz latonz changed the title Assign default value to missing required property or constructor parameter Map constant value to properties and constructor parameters Aug 8, 2023
@TimothyMakkison
Copy link
Collaborator

TimothyMakkison commented Aug 8, 2023

Unfortunately [MapValue(string[] targetPropertyPath, object value)] wouldn't be compatible with a user generated type. Attribute arguments must be constant at compile time, ie primitives (int, char), string or an array.

[MapValue("RequiredProperty", new Dog())] results in an error.

An alternative or fallback [MapValue(string[] targetPropertyPath, string objectFactoryPath)] could work.

@latonz
Copy link
Contributor

latonz commented Aug 8, 2023

In a first version only primitive values would be supported. Later other attribute constructor overloads could be added, which allow passing an enum for „special non-constant values“ (creation of a new instance by calling a parameterless constructor, default for value types, …)

@cremor
Copy link
Author

cremor commented Aug 8, 2023

default for value types

How is default a "special non-constant value"? default is compile time constant.

@latonz
Copy link
Contributor

latonz commented Aug 8, 2023

default is a keyword / value expression / operator, it can be used for attribute constructors. However, if the attribute ctor accepts object? this is always null, it cannot of a runtime target type of the project where Mapperly is applied.

In a first version of this feature, even assigning value types default values and instantiating new values via a parameterless constructor could be achieved by passing null as attribute value together with null handling options (ThrowOnPropertyMappingNullMismatch = false).

@cremor
Copy link
Author

cremor commented Aug 8, 2023

I don't know how source generators work, but can't you just take the default language token and place it in the generated code? Or to be more generic, take anything the user writes as attribute constructor parameter and copy the code as-is?

If this is not possible, then I assume default alone wouldn't work for an object? attribute constructor parameter because it is target typed? But default(int) should work, right?

Maybe also consider making the new attribute generic? (Generic attributes are possible since c# 11)

@latonz
Copy link
Contributor

latonz commented Aug 8, 2023

I don't think we need to make the attribute generic. We can just assume, null is default for value types and it should work.

Mapperly usually works on the semantic model and not on the syntax, that's why it is not easily possible to tell whether null or default was used (it shouldn't matter here anyway). And we recently implemented syntax access when reading configuration attirbutes here.

@jusexton
Copy link

jusexton commented Jan 6, 2024

It would also be advantageous to have the ability to map values using a function result instead of being constrained to using only constant values.

Something like the below:

public record Foo(string Id, string Name);

public record Bar(string Name);

[Mapper]
public class MyMapper
{
    [MapValue(nameof(Foo.Id), nameof(GenerateId))]
    public partial Foo ToFoo(Bar bar);

    private string GenerateId()
    {
        // Id generation code...
    }
}

@cremor
Copy link
Author

cremor commented Jan 6, 2024

I'm not so sure about that one. It could lead to a "logic in mapper" approach which is an antipattern.
That's why I explicitly stated "constant" in the title of this issue.

@jusexton
Copy link

jusexton commented Jan 6, 2024

What distinguishes the above "logic in mapper" approach from the already existing functionalities of Before/After Map and User Implemented Map?

@cremor
Copy link
Author

cremor commented Jan 7, 2024

Good point. All of that can also be misused.
I agree that this could be useful for something like Id generation code.

@latonz
Copy link
Contributor

latonz commented Jan 10, 2024

User implemented map is to support type conversions not supported by Mapperly.
Before/After Map indeed brings the risk of users implementing logic in a mapper.
In a first iteration, I'd only support constant values. If can support method calls in later iteration of the feature. It probably needs to be implemented with a property on the attribute or similar as the 2 string parameters constructor will already be taken for the constant value mapping.

@jusexton
Copy link

Is it accurate to say that, despite initially planning to only support constant primitives, we still need to determine the overall structure of the API incorporating all the features discussed above?

Maybe something like the below?

public record Foo(string A, string B, Baz C, string D);

public record Bar(string D);

[Mapper]
public class MyMapper
{
    [MapValue(nameof(Foo.A), From = "constant string")]
    [MapValue(nameof(Foo.B), FromMethod = nameof(GenerateId))]
    [MapValue(nameof(Foo.C), FromFactory = nameof(CreateBaz))]
    public partial Foo ToFoo(Bar bar);

    private string GenerateId()
    {
        // Id generation code...
    }

    [ObjectFactory]
    private Baz CreateBaz()
    {
        // Instantiate and return Baz object...
    }
}

This resolves the concern of potential conflicts among different string overloads, but it doesn't guarantee compile-time safety. An error will need to be raised if precisely one of the three properties referenced above is not provided.

@latonz latonz self-assigned this Mar 19, 2024
latonz added a commit that referenced this issue Jun 20, 2024
The refactored member matching works as follows:
* A member matching state and context is created
* For each target member a matching source is looked up
  Now this happens unified for all types (constructor parameter, init property, ...)
* A mapping is created with a MemberMappingInfo containing the information which members are mapped.
* The mapping is added to the container. Based on the MemberMappingInfo the members are marked as mapped.

* refactors and improves the readability of the member matching process
* Introduces a new IgnoredMembersBuilder to build and validate ignored members
* Introduces a new MemberMappingDiagnosticReporter to report all diagnostics after the member mapping build phase
* Introduces a new NestedMappingsContext to handle nested mappings (MapPropertyFromSourceAttribute)
* Introduces a new MemberMappingBuilder to build member (assignment) mappings for all types (objects, existing objects and tuples)
* Introduces a new MembersMappingState holding the state during the member matching process (which members are not yet mapped, which are ignored, ...)
* replace RMG017 and RMG027 with a new RMG074 when a target member path is used where it is not possible
* Introduces a new MemberMappingInfo representing the mapped source and target members. This simplifies marking members as mapped
* Introduces a new ConstructorParameterMember to simplify constructor parameter handling (can be treated as IMappableMember now)
* Introduces a new ISourceValue interface which represents the right side of a member assignment mapping.
  For now only mapped source members and null mapped source members are supported.
  With #631 constant values and method provided values will also be ISourceValues

BREAKING CHANGE: Replaces RMG017 and RMG027 with RMG074
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
enhancement New feature or request
Projects
None yet
Development

Successfully merging a pull request may close this issue.

4 participants