Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions NEXT_RELEASE_CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@

### Bugs

* Fix `@DeepClone` to clone mutable `java.util.Date` and `java.util.Calendar` fields instead of copying references (#3931)

### Documentation

### Build
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@
import org.mapstruct.ap.internal.model.MethodReference;
import org.mapstruct.ap.internal.model.SupportingField;
import org.mapstruct.ap.internal.model.SupportingMappingMethod;
import org.mapstruct.ap.internal.model.TypeConversion;
import org.mapstruct.ap.internal.model.common.Assignment;
import org.mapstruct.ap.internal.model.common.ConversionContext;
import org.mapstruct.ap.internal.model.common.DefaultConversionContext;
Expand Down Expand Up @@ -78,6 +79,15 @@ public class MappingResolverImpl implements MappingResolver {

private static final int LIMIT_REPORTING_AMBIGUOUS = 5;

private static final String JL_OBJECT_NAME = Object.class.getName();

/**
* Fully-qualified names of mutable JDK types that must be cloned when direct mapping is disabled
* (e.g. {@code @DeepClone}), instead of sharing a reference.
*/
private static final String JAVA_UTIL_DATE = "java.util.Date";
private static final String JAVA_UTIL_CALENDAR = "java.util.Calendar";

private final FormattingMessager messager;
private final TypeUtils typeUtils;
private final TypeFactory typeFactory;
Expand All @@ -91,8 +101,6 @@ public class MappingResolverImpl implements MappingResolver {

private final boolean verboseLogging;

private static final String JL_OBJECT_NAME = Object.class.getName();

/**
* Private methods which are not present in the original mapper interface and are added to map certain property
* types.
Expand Down Expand Up @@ -262,6 +270,17 @@ && allowDirect( sourceType, targetType ) ) {
return null;
}

// Mutable JDK types (Date, Calendar) from the java package are normally always assigned
// directly, even when DIRECT is disabled. That would share a mutable reference (e.g. with
// @DeepClone), so clone instead once direct assignment was ruled out above.
if ( !hasQualfiers() ) {
Assignment javaTypeClone = resolveMutableJavaTypeClone( sourceType, targetType );
if ( javaTypeClone != null ) {
javaTypeClone.setAssignment( sourceRHS );
return javaTypeClone;
}
}

// then type conversion
if ( allowConversion() ) {
if ( !hasQualfiers() ) {
Expand Down Expand Up @@ -416,9 +435,53 @@ private boolean allowDirect(Type type) {
( allowDirect( typeParameters.get( 0 ) ) && allowDirect( typeParameters.get( 1 ) ) );
}

// Mutable JDK types must not be assigned by reference when DIRECT is disabled
// (e.g. @DeepClone). See resolveMutableJavaTypeClone for the corresponding clone.
if ( isMutableJavaTypeRequiringClone( type ) ) {
return false;
}

return type.isJavaLangType();
}

/**
* {@link java.util.Date} and {@link java.util.Calendar} are mutable. When DIRECT is disabled they
* must not fall under the general "always direct for {@code java.*}" rule.
*/
private boolean isMutableJavaTypeRequiringClone(Type type) {
String fqn = type.getFullyQualifiedName();
return JAVA_UTIL_DATE.equals( fqn ) || JAVA_UTIL_CALENDAR.equals( fqn );
}

/**
* Builds an inline clone for same-type mapping of known mutable JDK types.
* Returns {@code null} when the pair is not handled here.
*/
private Assignment resolveMutableJavaTypeClone(Type sourceType, Type targetType) {
String sourceFqn = sourceType.getFullyQualifiedName();
if ( !sourceFqn.equals( targetType.getFullyQualifiedName() ) ) {
return null;
}

if ( JAVA_UTIL_DATE.equals( sourceFqn ) ) {
return new TypeConversion(
Collections.asSet( sourceType ),
java.util.Collections.emptyList(),
"new Date( <SOURCE>.getTime() )"
);
}

if ( JAVA_UTIL_CALENDAR.equals( sourceFqn ) ) {
return new TypeConversion(
Collections.asSet( sourceType ),
java.util.Collections.emptyList(),
"((Calendar) <SOURCE>.clone())"
);
}

return null;
}

private boolean allowConversion() {
return selectionCriteria != null && selectionCriteria.isAllowConversion();
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
/*
* Copyright MapStruct Authors.
*
* Licensed under the Apache License version 2.0, available at http://www.apache.org/licenses/LICENSE-2.0
*/
package org.mapstruct.ap.test.bugs._3931;

import java.util.Calendar;
import java.util.Date;

public class BeanA {

private Date date;
private Calendar calendar;
private String name;

public Date getDate() {
return date;
}

public void setDate(Date date) {
this.date = date;
}

public Calendar getCalendar() {
return calendar;
}

public void setCalendar(Calendar calendar) {
this.calendar = calendar;
}

public String getName() {
return name;
}

public void setName(String name) {
this.name = name;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
/*
* Copyright MapStruct Authors.
*
* Licensed under the Apache License version 2.0, available at http://www.apache.org/licenses/LICENSE-2.0
*/
package org.mapstruct.ap.test.bugs._3931;

import org.mapstruct.Mapper;
import org.mapstruct.control.DeepClone;
import org.mapstruct.factory.Mappers;

@Mapper(mappingControl = DeepClone.class)
public interface Issue3931Mapper {

Issue3931Mapper INSTANCE = Mappers.getMapper( Issue3931Mapper.class );

BeanA clone(BeanA source);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
/*
* Copyright MapStruct Authors.
*
* Licensed under the Apache License version 2.0, available at http://www.apache.org/licenses/LICENSE-2.0
*/
package org.mapstruct.ap.test.bugs._3931;

import java.util.Calendar;
import java.util.Date;
import java.util.GregorianCalendar;

import org.mapstruct.ap.testutil.IssueKey;
import org.mapstruct.ap.testutil.ProcessorTest;
import org.mapstruct.ap.testutil.WithClasses;

import static org.assertj.core.api.Assertions.assertThat;

/**
* Verifies that {@code @DeepClone} creates independent copies of mutable JDK types such as
* {@link Date} and {@link Calendar}, instead of copying references.
*/
@IssueKey("3931")
@WithClasses({
BeanA.class,
Issue3931Mapper.class
})
class Issue3931Test {

@ProcessorTest
void shouldCloneDateInsteadOfCopyingReference() {
Date originalDate = new Date( 1_700_000_000_000L );

BeanA source = new BeanA();
source.setDate( originalDate );
source.setName( "test" );

BeanA clone = Issue3931Mapper.INSTANCE.clone( source );

assertThat( clone ).isNotNull();
assertThat( clone ).isNotSameAs( source );
assertThat( clone.getDate() ).isNotNull();
assertThat( clone.getDate() ).isEqualTo( originalDate );
assertThat( clone.getDate() ).isNotSameAs( originalDate );
assertThat( clone.getName() ).isEqualTo( "test" );

// Mutating the source date must not affect the clone
originalDate.setTime( 0L );
assertThat( clone.getDate().getTime() ).isEqualTo( 1_700_000_000_000L );
}

@ProcessorTest
void shouldCloneCalendarInsteadOfCopyingReference() {
Calendar originalCalendar = new GregorianCalendar( 2024, Calendar.JANUARY, 15, 10, 30, 0 );

BeanA source = new BeanA();
source.setCalendar( originalCalendar );

BeanA clone = Issue3931Mapper.INSTANCE.clone( source );

assertThat( clone ).isNotNull();
assertThat( clone.getCalendar() ).isNotNull();
assertThat( clone.getCalendar() ).isEqualTo( originalCalendar );
assertThat( clone.getCalendar() ).isNotSameAs( originalCalendar );

// Mutating the source calendar must not affect the clone
originalCalendar.set( Calendar.YEAR, 1999 );
assertThat( clone.getCalendar().get( Calendar.YEAR ) ).isEqualTo( 2024 );
}

@ProcessorTest
void shouldMapNullDateAndCalendar() {
BeanA source = new BeanA();
source.setDate( null );
source.setCalendar( null );

BeanA clone = Issue3931Mapper.INSTANCE.clone( source );

assertThat( clone ).isNotNull();
assertThat( clone.getDate() ).isNull();
assertThat( clone.getCalendar() ).isNull();
}

@ProcessorTest
void shouldReturnNullForNullSource() {
assertThat( Issue3931Mapper.INSTANCE.clone( null ) ).isNull();
}
}