javaimmutabilitydozer

How to map collections of immutable objects in Dozer


Inspired by this answer, I have wrote a custom conveter (you can find the whole working example in the Github repo). for Dozer to convert between:

public class MyEntity {
    private List<ObjectId> attachmentIds;

    public List<ObjectId> getAttachmentIds() { return attachmentIds; }

    public void setAttachmentIds(List<ObjectId> attachmentIds) {
        this.attachmentIds = attachmentIds;
    }
}

And its DTO:

public class MyEntityDto {
    private List<FileDataDto> attachments;

    public List<FileDataDto> getAttachments() { return attachments; }

    public void setAttachments(List<FileDataDto> attachments) {
        this.attachments = attachments;
    }
}

MyEntity holds only the ids for files stored in Mongo databse. Its DTO which is sent to frontend in JSON, is supposed to contain both id and a filename of a file (which is the content of the FileDataDto class). My Converter:

public class FileIdToFileDataConverter extends DozerConverter<ObjectId, FileDataDto> {
    public FileIdToFileDataConverter() {super(ObjectId.class, FileDataDto.class); }

    @Override
    public FileDataDto convertTo(ObjectId source, FileDataDto destination) {
        if (source == null) {
            return null;
        }
        FileDataDto fileData = destination == null ? new FileDataDto() : destination;
        fileData.setId(source.toString());
        // fetch the file from repository and update the name from db
        fileData.setFilename("myfile.txt");
        return fileData;
    }

    @Override
    public ObjectId convertFrom(FileDataDto source, ObjectId destination) {
        return source == null ? null : new ObjectId(source.getId());
    }
}

The conversion works as expected in the MyEntity -> MyEntityDto direction. However, it fails in the opposite. It uses the ObjectId created by Dozer (passed as the destination argument) instead of the one that is returned by the converter. This test

@Test
public void dtoToMyEntity() {
    MyEntityDto dto = new MyEntityDto();
    FileDataDto fileData = new FileDataDto();
    fileData.setFilename("file.txt");
    fileData.setId(new ObjectId().toString());
    dto.setAttachments(Arrays.asList(fileData));
    MyEntity myEntity = mapper.map(dto, MyEntity.class);
    assertEquals(fileData.getId(), myEntity.getAttachmentIds().get(0).toString());
}

Fails with an example message:

org.junit.ComparisonFailure: 
  Expected :56b0a9d110a937fc32a6db18
  Actual   :56b0a9d110a937fc32a6db19

You can find the whole test and configuration I use in the Github repo.

How to make the converter work in both ways?


Solution

  • It's connected with bug in dozer, which causes that custom converters are not used when mapped through API: https://github.com/DozerMapper/dozer/issues/242

    So you can provide either mapping through xml:

    <?xml version="1.0" encoding="UTF-8"?>
    <mappings xmlns="http://dozer.sourceforge.net"
              xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
              xsi:schemaLocation="http://dozer.sourceforge.net
              http://dozer.sourceforge.net/schema/beanmapping.xsd">
        <configuration>
            <custom-converters>
                <converter type="com.example.mapping.FileIdToFileDataConverter">
                    <class-a>org.bson.types.ObjectId</class-a>
                    <class-b>com.example.mapping.entity.FileDataDto</class-b>
                </converter>
            </custom-converters>
        </configuration>
        <mapping>
            <class-a>com.example.mapping.entity.MyEntity</class-a>
            <class-b>com.example.mapping.entity.MyEntityDto</class-b>
            <field>
                <a>attachmentIds</a>
                <b>attachments</b>
                <a-hint>org.bson.types.ObjectId</a-hint>
                <b-hint>com.example.mapping.entity.FileDataDto</b-hint>
            </field>
        </mapping>
    </mappings>
    

    And then

    mapper.setMappingFiles(Arrays.asList("dozerconfig.xml"));
    

    Or, if you don't want to use xml, you can create a workaround for using own ObjectIdFactory:

    mapping(type(ObjectId.class).beanFactory(ObjectIdFactory.class), FileDataDto.class)
        .fields(this_(), this_(), customConverter(FileIdToFileDataConverter.class));
    

    And the factory class

    public class ObjectIdFactory implements BeanFactory {
        @Override
        public Object createBean(Object source, Class<?> sourceClass, String targetBeanId) {
            if (source == null) {
                return null;
            }
            if (source instanceof ObjectId) {
                return source; // we can return source, because it's immutable
            }
            if (source instanceof String) {
                return new ObjectId((String) source);
            }
            if (source instanceof FileDataDto) {
                return new ObjectId(((FileDataDto) source).getId());
            }
            throw new MappingException("ObjectId should be of type ObjectId, String or FileDataDto");
        }
    }
    

    The reason why this workaroud works and why ids didn't match:

    Dozer by default uses no-args constructor of class to instantiate null values. ObjectId is immutable class and it's no-args constructor creates new instance based on timestamp.