Skip to content

Parallel source and target traversal with @ParallelSource #4066

@wernerdegroot

Description

@wernerdegroot

Use case

MapStruct provides excellent support for:

  • Mapping from a source object to a target object
  • Updating an existing mutable target object using @MappingTarget
  • Using builders when creating immutable target objects

However, there is currently no convenient way to perform a deep update of an immutable object graph while preserving information from the existing graph.

Consider:

record Child(
	// Internal:
	long id,
	OffsetDateTime createdAt,
	
	// Public:
	int foo
) {}

record Intermediate(
	// Public:
	boolean bar,
	Child child
) {}

record Parent(
	// Internal:
	long id,
	OffsetDateTime createdAt,
	
	// Public:
	String baz,
	Intermediate intermediate
) {}

This is an immutable structure with three layers of nesting.

Suppose I accept View objects through some API that only expose/accept the public properties:

record ChildView(
	int foo
) {}

record IntermediateView(
	boolean bar,
	ChildView child
) {}

record ParentView(
	String baz,
	IntermediateView intermediate
) {}

Mapping these is straightforward:

@Mapper(unmappedTargetPolicy = ReportingPolicy.ERROR)
interface ParentMapper {

	@Mapping(target = "id", ignore = true)
	@Mapping(target = "createdAt", ignore = true)
	Parent fromView(ParentView source);
	
	@Mapping(target = "id", ignore = true)
	@Mapping(target = "createdAt", ignore = true)
	Child fromView(ChildView source);
}

This works well for creation where id an timestamps of creation are generated elsewhere.

Updates, however, are more challenging. A typical update workflow looks like:

updated = merge(updateView, existingModel);

where:

  • Values present in the update should overwrite existing values
  • Fields not represented in the update should be preserved
  • Nested objects should be updated recursively

If my Parent, Intermediate and Child were mutable, it would be simple to patch/update these with values from a ParentView, IntermediateView and ChildView:

@Mapper(unmappedTargetPolicy = ReportingPolicy.ERROR)
interface ParentMapper {

	@Mapping(target = "id", ignore = true)
	@Mapping(target = "createdAt", ignore = true)
	Parent fromView(ParentView source);
	
	@Mapping(target = "id", ignore = true)
	@Mapping(target = "createdAt", ignore = true)
	Child fromView(ChildView source);
	
	// Don't overwrite `id` and `createdAt`
	@Mapping(target = "id", ignore = true)
	@Mapping(target = "createdAt", ignore = true)
	void patch(@MappingTarget Parent original, ParentView source);
	
	// Don't overwrite `id` and `createdAt`
	@Mapping(target = "id", ignore = true)
	@Mapping(target = "createdAt", ignore = true)
	void patch(@MappingTarget Child original, ChildView source);
}

Of course, this won't work because the Parent, Intermediate and Child are immutable records, not mutable classes.

I am hoping to approach the same convenience and safety with immutable records. The best thing I seem to be able to do is adding Lombok builders to my records and using the source object to initialize that with the attributes that don't exist in the View objects:

@Builder(toBuilder = true)
record Child(
	// Like before
) {}

@Builder(toBuilder = true)
record Intermediate(
	// Like before
) {}

@Builder(toBuilder = true)
record Parent(
	// Like before
) {}

with

@Mapper(unmappedTargetPolicy = ReportingPolicy.ERROR)
interface ParentMapper {

	@Mapping(target = "id", ignore = true)
	@Mapping(target = "createdAt", ignore = true)
	Parent fromView(ParentView source);
	
	@Mapping(target = "id", ignore = true)
	@Mapping(target = "createdAt", ignore = true)
	Child fromView(ChildView source);
	
	// Don't overwrite `id` and `createdAt`
	@Mapping(target = "id", ignore = true)
	@Mapping(target = "createdAt", ignore = true)
	@Mapping(target = "intermediate", expression = "java(patch(original.intermediate(), source.intermediate()))")
	Parent patch(@Context Parent original, ParentView source);

	@ObjectFactory	
	default ParentBuilder toBuilder(@Context Parent original) {
		// Seed builder with unmapped target attributes:
		return original == null
			? Parent.builder()
			: original.toBuilder();
	}
	
	@Mapping(target = "child", expression = "java(patch(original.child(), source.child()))")
	Intermediate patch(@Context Intermediate original, IntermediateView source);
	
	// Don't overwrite `id` and `createdAt`
	@Mapping(target = "id", ignore = true)
	@Mapping(target = "createdAt", ignore = true)
	Child patch(@Context Child original, ChildView source);
	
	@ObjectFactory	
	default ChildBuilder toBuilder(@Context Child original) {
		// Seed builder with unmapped target attributes:
		return original == null
			? Child.builder()
			: original.toBuilder();
	}
}

This becomes verbose for large object graphs.

Proposed solution

Introduce a new annotation to identify a “parallel source graph”, that has the same type as the target and is traversed in parallel with the target.

It's a bit like @Context and is not automatically considered as a source for mapping. Also, it resolves like @MappingTarget.

For example:

	// Don't overwrite `id` and `createdAt`
	@Mapping(target = "id", ignore = true)
	@Mapping(target = "createdAt", ignore = true)
	Parent patch(@ParallelSource Parent original, ParentView source);

When MapStruct generates nested mapping calls, it would automatically traverse the same property path through both the target and the parallel source object.

This generates something like:

// The .interediate(...) setter here...
builder.intermediate(
    patch(
		original.intermediate(),
		
		// ...corresponds with the `.intermediate()` getter here:
		source.intermediate()
    )
);

This replaces the need for a manual mapping like:

@Mapping(target = "intermediate", expression = "java(patch(original.intermediate(), source.intermediate()))")

Here's how that would look:

```java
@Mapper(unmappedTargetPolicy = ReportingPolicy.ERROR)
interface ParentMapper {

	@Mapping(target = "id", ignore = true)
	@Mapping(target = "createdAt", ignore = true)
	Parent fromView(ParentView source);
	
	@Mapping(target = "id", ignore = true)
	@Mapping(target = "createdAt", ignore = true)
	Child fromView(ChildView source);
	
	// Don't overwrite `id` and `createdAt`
	@Mapping(target = "id", ignore = true)
	@Mapping(target = "createdAt", ignore = true)
	Parent patch(@ParallelSource Parent original, ParentView source);

	@ObjectFactory	
	default ParentBuilder toBuilder(@ParallelSource Parent original) {
		// Seed builder with unmapped target attributes:
		return original == null
			? Parent.builder()
			: original.toBuilder();
	}
	
        // Could possible even be inferred?
	Intermediate patch(@ParallelSource Intermediate original, IntermediateView source);
	
	// Don't overwrite `id` and `createdAt`
	@Mapping(target = "id", ignore = true)
	@Mapping(target = "createdAt", ignore = true)
	Child patch(@ParallelSource Child original, ChildView source);
	
	@ObjectFactory	
	default ChildBuilder toBuilder(@ParallelSource Child original) {
		// Seed builder with unmapped target attributes:
		return original == null
			? Child.builder()
			: original.toBuilder();
	}
}

Benefits

Better support for immutable models

The feature significantly reduces boilerplate when working with immutable object graphs and builders.

Improved maintainability

Adding a new nested property would automatically participate in the traversal, instead of requiring manual expressions at every level.

Implementation

I'm willing to implement this feature, but I've never worked on MapStruct before so I would appreciate both some feedback on this proposal and some pointers on where to get started (if the proposal is received well of course!)

MapStruct Version

1.6.X

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions