-
-
Notifications
You must be signed in to change notification settings - Fork 916
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
#3303 Mapping from Map to Map and specify source and target keys of type String #3367
base: main
Are you sure you want to change the base?
Conversation
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Really nice PR @thunderhook. Thanks for your work on this.
I've left some comments in the PR review, still need to play with it to see the actual generated code.
Here are some comments for your notes:
The variable containing key mappings is not built safe like in ...
I've left a review comment for this. If we do it like I suggest there, I think that we can use the SupportingField#getSafeField
Only
@Mapping
annotations with setsource
andtarget
are considered, the rest is ignored, likeexpression
orignore
(I felt that it is not worth to throw an error in that case)
If we are going to start something that we didn't support before then in my opinion we should be as strict as possible. This means that it is fine to only support source
and target
. However, if someone sets something else then we should have a compile error.
Alternatively we can also go with a new annotation for this since for this source
is mandatory, but not target
. I do see people asking for things like we have in BeanMapping#ignoreByDefault
and maybe expanding more on how value for a particular key should be mapped using the qualifedByName
and stuff like this. Having said all of this I think it would be better for us if we go with a new annotation e.g. @MapKeyMapping
(open to other name suggestions as well)
I am not saying that we should add all this customizations in this PR, but I would like to anticipate things and make sure that we are implementing this right. We do have #3293 where something else around map mapping is being requested.
This PR tackles only mapping of maps with keys of type string (
Map<String, ?>
). Any other type does not feel like a valid use-case (well,CharSequence
eventually?)
That is completely OK. However, see my comment for the validations. We also need to add tests for mappings from Map<X, Y>
to Map<String, ?>
. We do support converting X
to String
through some kind of a mapping or if it is some of the out of the box mappings from X
to String
. Check org.mapstruct.ap.test.collection.map.SourceTargetMapper
as an example.
Map<String, Long> map(Map<Long, Long> source);
I found no easy solution to get a type of
Map<String, String>
with theTypeFactory
. If you have a solution for this, please let me know. I could then remove theMapKeyMappingField.ftl
and use a typedField.ftl
You can get the right map by doing something like:
In the TypeFactory
add:
public Type getMapType(String keyCanonicalName, String valueCanonicalName) {
TypeElement mapTypeElement = getTypeElement( Map.class.getCanonicalName() );
TypeElement keyTypeElement = getTypeElement( keyCanonicalName );
TypeElement valueTypeElement = getTypeElement( valueCanonicalName );
DeclaredType mapType = typeUtils.getDeclaredType(
mapTypeElement,
keyTypeElement.asType(),
valueTypeElement.asType()
);
return getType( mapType );
}
The getTypeElement
looks like:
private TypeElement getTypeElement(String canonicalName) {
TypeElement typeElement = elementUtils.getTypeElement( canonicalName );
if ( typeElement == null ) {
throw new AnnotationProcessingException(
"Couldn't find type " + canonicalName + ". Are you missing a dependency on your classpath?"
);
}
return typeElement;
}
This is extracted from private Type getType(String canonicalName, boolean isLiteral)
@@ -39,6 +41,8 @@ public class MapMappingMethod extends NormalTypeMappingMethod { | |||
private final Assignment valueAssignment; | |||
private final Parameter sourceParameter; | |||
private final PresenceCheck sourceParameterPresenceCheck; | |||
private final MapKeyMappingConstructorFragment mapKeyMappingConstructorFragment; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
We only need this here because it is used in the MapperCreationProcessor
. However, if you look at what we do there we do
Set<Field> supportingFieldSet = new LinkedHashSet<>(mappingContext.getUsedSupportedFields());
which means that we can easily add the supported fields in the mapping context in the builder for the MapMappingMethod
. doing it there would also making it possible to create the fields using SupportingField#getSafeField
For the constructor fragments. We can add a Set<ConstructorFragment>
and do it in a similar way as we are doing for the supported fields.
* | ||
* @author Oliver Erhart | ||
*/ | ||
public class MapKeyMappingConstructor extends ModelElement implements Constructor { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Where is this used?
|
||
--> | ||
<#-- @ftlvariable name="" type="org.mapstruct.ap.internal.model.MapKeyMappingConstructorFragment" --> | ||
this.${field.variableName} = new java.util.HashMap<>(); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
For this you can then use an IterableCreation
in the MapKeyMappingConstructorFragment
. Most likely we'll need a new method in IterableCreation
e.g.
public static IterableCreation create(Type resultType) {
return new IterableCreation( resultType, null, null );
}
I think that the new line will then be
this.${field.variableName} = <@includeModel object=iterableCreation useSizeIfPossible=false/>;
assertThat( target ).isNotNull() | ||
.hasSize( 2 ) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
isNotNull
and hasSize
are obsolete since we are doing isEqualTo
already
assertThat( target ).isNotNull() | ||
.hasSize( 2 ) | ||
.contains( | ||
entry( "targetKey", "value" ), | ||
entry( "unmappedKey", "otherValue" ) | ||
); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
we can remove isNotNull
and hasSize
if we use containsOnly
instead of contains
final GeneratedSource generatedSource = new GeneratedSource(); | ||
|
||
@ProcessorTest | ||
public void shouldContainTwoKeyMappingMapsInConstructor() { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I would prefer if we have a fixture for this and we test the contents. Makes it a bit easier to see what gets generated as well
CustomNumberMapper.class, | ||
Source.class, | ||
Target.class, | ||
ImportedType.class |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Why are these needed? I guess it is a copy paste thing. Let's remove it if that is the case
Thank you for your detailed and thorough review.
Agreed with a new annotation (didn't think of the I guess this should also be pre JDK-8 compatible? So there will be
Yeah. I will start with the "basic" key mapping feature via Thank you for the detailled comments (especially about the type(s)), I will incorporate them as I refactor the code. |
The need of Side note, can you please reach out to me on my email? You can find it in any of my commits. |
@filiphr I made good progress but then I confused myself with the conversion of
Scenario 1 Mapping Long to String without custom mapping methodLet's say that company 1 and 2 need to be switched. With the original implementation that would've looked like... @MapKeyMapping( target = "1L", source = "2L" )
@MapKeyMapping( target = "2L", source = "1L" )
Map<String, Long> mapLongKeyMapToStringKeyMap(Map<Long, Long> source);
// GENERATED CODE
private final Map<String, String> mapLongKeyMapToStringKeyMapKeyMappings;
public MapKeyMappingCommonConversionMapperImpl() {
this.mapLongKeyMapToStringKeyMapKeyMappings = new LinkedHashMap<String, String>();
this.mapLongKeyMapToStringKeyMapKeyMappings.put( "1L", "2L" );
this.mapLongKeyMapToStringKeyMapKeyMappings.put( "2L", "1L" );
}
public Map<String, Long> mapLongKeyMapToStringKeyMap(Map<Long, Long> source) {
Map<String, Long> map = new LinkedHashMap<String, Long>();
for ( java.util.Map.Entry<Long, Long> entry : source.entrySet() ) {
String key = String.valueOf( entry.getKey() ); // generates "1" instead of "1L"
Long value = entry.getValue();
map.put( this.mapLongKeyMapToStringKeyMapKeyMappings.getOrDefault( key, key ), value ); // "1" != "1L"
}
return map;
} If there is no custom mapping method from Long to String, Scenario 2: Mapping Long to String with custom mappingLet's say we have a custom mapping method (which is not qualified, since String map(Long value) {
return "Company" + value;
} How should map key mappings be defined?
// Option 1
@MapKeyMapping( target = "Company1", source = "2L" )
@MapKeyMapping( target = "Company2", source = "1L" )
Map<String, Long> option1(Map<Long, Long> source);
// Option 2
@MapKeyMapping( target = "Company1", source = "Company2" )
@MapKeyMapping( target = "Company2", source = "Company1" )
Map<String, Long> option2(Map<Long, Long> source);
// Option 3
@MapKeyMapping( target = "1L", source = "2L" )
@MapKeyMapping( target = "2L", source = "1L" )
Map<String, Long> option3(Map<Long, Long> source);
// GENERATED CODE
private final Map<Long, String> option1KeyMappings;
private final Map<String, String> option2KeyMappings;
private final Map<Long, Long> option3KeyMappings;
public MapKeyMappingCommonConversionMapperImpl() {
this.option1KeyMappings = new LinkedHashMap<Long, String>();
this.option1KeyMappings.put( (long) 1L, "Company2" );
this.option1KeyMappings.put( (long) 2L, "Company1" );
this.option2KeyMappings = new LinkedHashMap<Long, String>();
this.option2KeyMappings.put( "Company1", "Company2" );
this.option2KeyMappings.put( "Company2", "Company1" );
this.option3KeyMappings = new LinkedHashMap<Long, Long>();
this.option3KeyMappings.put( (long) 1L, (long) 2L );
this.option3KeyMappings.put( (long) 2L, (long) 1L );
} Option 1Using a diffrent type between source and target needs a different access inside the mapping method: // GENERATED CODE
@Override
public Map<String, Long> option1(Map<Long, Long> source) {
if ( source == null ) {
return null;
}
Map<String, Long> map = new LinkedHashMap<String, Long>( Math.max( (int) ( source.size() / .75f ) + 1, 16 ) );
for ( java.util.Map.Entry<Long, Long> entry : source.entrySet() ) {
String key = String.valueOf( entry.getKey() );
Long value = entry.getValue();
Long sourceMappingKey = entry.getKey();
map.put( this.option1KeyMappings.getOrDefault( sourceMappingKey, key ), value );
}
return map;
} Implementation-wise I am certain that we should go with option 1. @MapKeyMapping( target = "Company1", source = "NOT_A_LONG") // cannot generate mapper
Map<String, Long> mapLongKeyMapToStringKeyMap(Map<Long, Long> source); Cons: seems unintuitive to define different types Remember the problem from Scenario 1 with Option 2I think the second option is most understandable from a users perspective. Or is it? I am not sure. However this can lead to wrong (and missed) mappings. @MapKeyMapping( target = "Company1", source = "CompanyThatNeverHits")
Map<String, Long> mapLongKeyMapToStringKeyMap(Map<Long, Long> source); This is just not mapped and the user doesn't get informed. Option 3I don't think that option 3 is really an option. Just using the source key type is hard to understand, right? Scenario 3 Map to (or even from) an enumI reuse the code to get the expression of source/target like it is handled with the Let's say we have a map with month and employees count, and all employees from @MapKeyMapping( target = "FEBRUARY", source = "MARCH" )
@MapKeyMapping( target = "MARCH", source = "FEBRUARY" )
Map<String, Long> map(Map<java.util.Month, Long> source); This works. It does not matter if the key mapping map is backed with a String or Month map. Let's add a custom enum conversion method: String mapMonth(Month value) {
return value.name().substring(0, 3);
} How about the mapping? Must it be changed to the following? This example should help to chose for an option from above // option 1
@MapKeyMapping( target = "FEB", source = "MARCH" )
@MapKeyMapping( target = "MAR", source = "FEBRUARY" )
// option 2
@MapKeyMapping( target = "FEB", source = "MAR" )
@MapKeyMapping( target = "MAR", source = "FEB" )
// option 3
@MapKeyMapping( target = "FEBRUARY", source = "MARCH" )
@MapKeyMapping( target = "MARCH", source = "FEBRUARY" )
Map<String, Long> map(Map<java.util.Month, Long> source); Scenario 4 (bonus) Mapping from Long to Long (or: same source key type as target key type)I can see no reason why this should not work. And this will work when the key mapping map supports different types than String. This is currently implemented and working as expected. @MapKeyMapping( taraget = "1L", source = "2L")
@MapKeyMapping( taraget = "2L", source = "1L")
Map<Long, Long> mapLongsToLongs(Map<Long, Long> source); ConclusionDuring writing this wall of text (took me like an hour...) I could think about it more and analyze it. I want to know your opinion about the possible options, and what would you choose? Thanks in advance. Take your time to answer it. I would love to push my current code, but it is really mixed up with those three options. |
Tackling #3303 map mapping with source and target keys.
Things to note:
org.mapstruct.ap.internal.model.SupportingField#getSafeField
. This would need a bigger change in theMapperCreationProcessor
. However, a collision with a field named likemethod.getName() + "KeyMappings"
is very unlikely.@Mapping
annotations with setsource
andtarget
are considered, the rest is ignored, likeexpression
orignore
(I felt that it is not worth to throw an error in that case)Map<String, ?>
). Any other type does not feel like a valid use-case (well,CharSequence
eventually?)Map<String, String>
in the FreeMarker template.Map<String, String>
with theTypeFactory
. If you have a solution for this, please let me know. I could then remove theMapKeyMappingField.ftl
and use a typedField.ftl