New posts for Spring and Hibernate
This commit is contained in:
@@ -0,0 +1,22 @@
|
||||
import org.mapstruct.Mapper;
|
||||
|
||||
/** Two methods can turn a Money into a String, and nothing says which one a property should use. */
|
||||
@Mapper
|
||||
public interface BrokenMapper {
|
||||
|
||||
record Money(long cents) { }
|
||||
|
||||
record Src(Money price) { }
|
||||
|
||||
record Dst(String price) { }
|
||||
|
||||
Dst map(Src src);
|
||||
|
||||
default String plain(Money m) {
|
||||
return String.valueOf(m.cents());
|
||||
}
|
||||
|
||||
default String formatted(Money m) {
|
||||
return m.cents() / 100 + "." + m.cents() % 100;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import org.mapstruct.Mapper;
|
||||
|
||||
/** MapStruct can map String to String and Tag to Tag-like beans, but not a Tag to a String. */
|
||||
@Mapper
|
||||
public interface BrokenMapper {
|
||||
|
||||
class Tag {
|
||||
private final String label;
|
||||
public Tag(String label) { this.label = label; }
|
||||
public String getLabel() { return label; }
|
||||
}
|
||||
|
||||
record Src(Tag tag) { }
|
||||
|
||||
record Dst(java.time.LocalDate tag) { }
|
||||
|
||||
Dst map(Src src);
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import org.mapstruct.DecoratedWith;
|
||||
import org.mapstruct.Mapper;
|
||||
|
||||
/** A decorator that wants its delegate through the constructor, with componentModel = "spring". */
|
||||
@Mapper(componentModel = "spring")
|
||||
@DecoratedWith(BrokenMapperDecorator.class)
|
||||
public interface BrokenMapper {
|
||||
|
||||
record Src(String name) { }
|
||||
|
||||
record Dst(String name) { }
|
||||
|
||||
Dst map(Src src);
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
public abstract class BrokenMapperDecorator implements BrokenMapper {
|
||||
|
||||
private final BrokenMapper delegate;
|
||||
|
||||
protected BrokenMapperDecorator(BrokenMapper delegate) {
|
||||
this.delegate = delegate;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Dst map(Src src) {
|
||||
return delegate.map(src);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
import org.mapstruct.Mapper;
|
||||
import org.mapstruct.ReportingPolicy;
|
||||
|
||||
/** A Lombok source bean mapped to a record. Whether it compiles depends only on the processor path. */
|
||||
@Mapper(unmappedTargetPolicy = ReportingPolicy.ERROR)
|
||||
public interface LombokMapper {
|
||||
|
||||
@Getter
|
||||
@Setter
|
||||
class Person {
|
||||
private String name;
|
||||
private int age;
|
||||
}
|
||||
|
||||
record PersonDto(String name, int age) { }
|
||||
|
||||
PersonDto map(Person person);
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import org.mapstruct.Mapper;
|
||||
import org.mapstruct.Mapping;
|
||||
|
||||
/** A typo in source = "...": the property does not exist on the source record. */
|
||||
@Mapper
|
||||
public interface BrokenMapper {
|
||||
|
||||
record Src(String name) { }
|
||||
|
||||
record Dst(String title) { }
|
||||
|
||||
@Mapping(source = "nmae", target = "title")
|
||||
Dst map(Src src);
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import org.mapstruct.Mapper;
|
||||
import org.mapstruct.factory.Mappers;
|
||||
|
||||
/** Compiled with the processor jar on the plain classpath, the way a pre-JDK-23 build was often set up. */
|
||||
@Mapper
|
||||
public interface UsesMapper {
|
||||
|
||||
record Src(String name) { }
|
||||
|
||||
record Dst(String name) { }
|
||||
|
||||
Dst map(Src src);
|
||||
|
||||
static void main(String[] args) {
|
||||
System.out.println(Mappers.getMapper(UsesMapper.class).map(new Src("x")));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import org.mapstruct.Mapper;
|
||||
import org.mapstruct.ReportingPolicy;
|
||||
|
||||
/** The target record has a component (note) that the source does not have. */
|
||||
@Mapper(unmappedTargetPolicy = ReportingPolicy.ERROR)
|
||||
public interface BrokenMapper {
|
||||
|
||||
record Src(String name, int qty) { }
|
||||
|
||||
record Dst(String name, int qty, String note) { }
|
||||
|
||||
Dst map(Src src);
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import org.mapstruct.Mapper;
|
||||
import org.mapstruct.MappingTarget;
|
||||
|
||||
/** An update method whose @MappingTarget is a record: there is nothing to update in place. */
|
||||
@Mapper(unmappedTargetPolicy = org.mapstruct.ReportingPolicy.ERROR)
|
||||
public interface BrokenMapper {
|
||||
|
||||
record Patch(String name) { }
|
||||
|
||||
record Dst(String name) { }
|
||||
|
||||
void update(Patch patch, @MappingTarget Dst target);
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package com.ankurm.mapstruct;
|
||||
|
||||
import java.time.Clock;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
|
||||
/** Mappers never read the wall clock themselves; they are handed this one as a context parameter. */
|
||||
@Configuration
|
||||
public class ClockConfig {
|
||||
|
||||
@Bean
|
||||
Clock clock() {
|
||||
return Clock.systemUTC();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package com.ankurm.mapstruct;
|
||||
|
||||
import org.springframework.boot.SpringApplication;
|
||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||
|
||||
@SpringBootApplication
|
||||
public class MapstructApplication {
|
||||
|
||||
public static void main(String[] args) {
|
||||
SpringApplication.run(MapstructApplication.class, args);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package com.ankurm.mapstruct.domain;
|
||||
|
||||
/** A mutable entity-style class: no-arg constructor plus setters. MapStruct fills it through the setters. */
|
||||
public class Address {
|
||||
private String street;
|
||||
private String city;
|
||||
private String postalCode;
|
||||
private String country;
|
||||
|
||||
public Address() { }
|
||||
|
||||
public Address(String street, String city, String postalCode, String country) {
|
||||
this.street = street; this.city = city; this.postalCode = postalCode; this.country = country;
|
||||
}
|
||||
|
||||
public String getStreet() { return street; }
|
||||
public void setStreet(String street) { this.street = street; }
|
||||
public String getCity() { return city; }
|
||||
public void setCity(String city) { this.city = city; }
|
||||
public String getPostalCode() { return postalCode; }
|
||||
public void setPostalCode(String postalCode) { this.postalCode = postalCode; }
|
||||
public String getCountry() { return country; }
|
||||
public void setCountry(String country) { this.country = country; }
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
package com.ankurm.mapstruct.domain;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDate;
|
||||
import java.util.HashSet;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
|
||||
/** The persistence-side shape: mutable, with a field initialiser (tier) that a careless mapper can wipe out. */
|
||||
public class Customer {
|
||||
private Long id;
|
||||
private String firstName;
|
||||
private String lastName;
|
||||
private String email;
|
||||
private String passwordHash;
|
||||
private LocalDate birthDate;
|
||||
private String nickname;
|
||||
private String tier = "STANDARD";
|
||||
private Address address;
|
||||
private Set<String> roles = new HashSet<>();
|
||||
private Map<String, String> attributes = new LinkedHashMap<>();
|
||||
private List<Order> orders;
|
||||
private Instant createdAt;
|
||||
private Instant updatedAt;
|
||||
|
||||
public Long getId() { return id; }
|
||||
public void setId(Long id) { this.id = id; }
|
||||
public String getFirstName() { return firstName; }
|
||||
public void setFirstName(String firstName) { this.firstName = firstName; }
|
||||
public String getLastName() { return lastName; }
|
||||
public void setLastName(String lastName) { this.lastName = lastName; }
|
||||
public String getEmail() { return email; }
|
||||
public void setEmail(String email) { this.email = email; }
|
||||
public String getPasswordHash() { return passwordHash; }
|
||||
public void setPasswordHash(String passwordHash) { this.passwordHash = passwordHash; }
|
||||
public LocalDate getBirthDate() { return birthDate; }
|
||||
public void setBirthDate(LocalDate birthDate) { this.birthDate = birthDate; }
|
||||
public String getNickname() { return nickname; }
|
||||
public void setNickname(String nickname) { this.nickname = nickname; }
|
||||
public String getTier() { return tier; }
|
||||
public void setTier(String tier) { this.tier = tier; }
|
||||
public Address getAddress() { return address; }
|
||||
public void setAddress(Address address) { this.address = address; }
|
||||
public Set<String> getRoles() { return roles; }
|
||||
public void setRoles(Set<String> roles) { this.roles = roles; }
|
||||
public Map<String, String> getAttributes() { return attributes; }
|
||||
public void setAttributes(Map<String, String> attributes) { this.attributes = attributes; }
|
||||
public List<Order> getOrders() { return orders; }
|
||||
public void setOrders(List<Order> orders) { this.orders = orders; }
|
||||
public Instant getCreatedAt() { return createdAt; }
|
||||
public void setCreatedAt(Instant createdAt) { this.createdAt = createdAt; }
|
||||
public Instant getUpdatedAt() { return updatedAt; }
|
||||
public void setUpdatedAt(Instant updatedAt) { this.updatedAt = updatedAt; }
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package com.ankurm.mapstruct.domain;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
|
||||
public class Order {
|
||||
private Long id;
|
||||
private OrderStatus status;
|
||||
private Instant placedAt;
|
||||
private List<OrderLine> lines;
|
||||
|
||||
public Order() { }
|
||||
|
||||
public Order(Long id, OrderStatus status, Instant placedAt, List<OrderLine> lines) {
|
||||
this.id = id; this.status = status; this.placedAt = placedAt; this.lines = lines;
|
||||
}
|
||||
|
||||
public Long getId() { return id; }
|
||||
public void setId(Long id) { this.id = id; }
|
||||
public OrderStatus getStatus() { return status; }
|
||||
public void setStatus(OrderStatus status) { this.status = status; }
|
||||
public Instant getPlacedAt() { return placedAt; }
|
||||
public void setPlacedAt(Instant placedAt) { this.placedAt = placedAt; }
|
||||
public List<OrderLine> getLines() { return lines; }
|
||||
public void setLines(List<OrderLine> lines) { this.lines = lines; }
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package com.ankurm.mapstruct.domain;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
|
||||
public class OrderLine {
|
||||
private String sku;
|
||||
private int quantity;
|
||||
private BigDecimal unitPrice;
|
||||
|
||||
public OrderLine() { }
|
||||
|
||||
public OrderLine(String sku, int quantity, BigDecimal unitPrice) {
|
||||
this.sku = sku; this.quantity = quantity; this.unitPrice = unitPrice;
|
||||
}
|
||||
|
||||
public String getSku() { return sku; }
|
||||
public void setSku(String sku) { this.sku = sku; }
|
||||
public int getQuantity() { return quantity; }
|
||||
public void setQuantity(int quantity) { this.quantity = quantity; }
|
||||
public BigDecimal getUnitPrice() { return unitPrice; }
|
||||
public void setUnitPrice(BigDecimal unitPrice) { this.unitPrice = unitPrice; }
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
package com.ankurm.mapstruct.domain;
|
||||
|
||||
public enum OrderStatus { NEW, PAID, SHIPPED, CANCELLED }
|
||||
@@ -0,0 +1,4 @@
|
||||
package com.ankurm.mapstruct.dto;
|
||||
|
||||
/** Note zip: the entity calls it postalCode, which is what makes the rename worth mapping. */
|
||||
public record AddressDto(String street, String city, String zip, String country) { }
|
||||
@@ -0,0 +1,20 @@
|
||||
package com.ankurm.mapstruct.dto;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
|
||||
/** What the API returns. A record: immutable, no setters; MapStruct builds it through its canonical constructor. */
|
||||
public record CustomerDto(
|
||||
Long id,
|
||||
String fullName,
|
||||
String email,
|
||||
LocalDate birthDate,
|
||||
int age,
|
||||
String nickname,
|
||||
AddressDto address,
|
||||
Set<String> roles,
|
||||
Map<String, String> attributes,
|
||||
List<OrderDto> orders,
|
||||
String origin) { }
|
||||
@@ -0,0 +1,6 @@
|
||||
package com.ankurm.mapstruct.dto;
|
||||
|
||||
import java.util.Set;
|
||||
|
||||
/** A partial update: every component may be null, and null is supposed to mean leave it alone. */
|
||||
public record CustomerPatch(String email, String nickname, AddressDto address, Set<String> roles) { }
|
||||
@@ -0,0 +1,15 @@
|
||||
package com.ankurm.mapstruct.dto;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.util.Set;
|
||||
|
||||
/** What the API accepts when creating a customer. A record used as a mapping source. */
|
||||
public record CustomerRequest(
|
||||
String firstName,
|
||||
String lastName,
|
||||
String email,
|
||||
LocalDate birthDate,
|
||||
String nickname,
|
||||
String tier,
|
||||
AddressDto address,
|
||||
Set<String> roles) { }
|
||||
@@ -0,0 +1,5 @@
|
||||
package com.ankurm.mapstruct.dto;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
|
||||
public record InvoiceDto(Long id, String status, BigDecimal total, String display) { }
|
||||
@@ -0,0 +1,7 @@
|
||||
package com.ankurm.mapstruct.dto;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
|
||||
public record OrderDto(Long id, String status, Instant placedAt, List<OrderLineDto> lines, BigDecimal total) { }
|
||||
@@ -0,0 +1,5 @@
|
||||
package com.ankurm.mapstruct.dto;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
|
||||
public record OrderLineDto(String sku, int quantity, BigDecimal unitPrice, BigDecimal lineTotal) { }
|
||||
@@ -0,0 +1,25 @@
|
||||
package com.ankurm.mapstruct.dto;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
|
||||
/**
|
||||
* A record that ships its own builder. That is the one way MapStruct can run post-processing code
|
||||
* against something still mutable: the hook receives the builder, not the finished record.
|
||||
*/
|
||||
public record ReceiptDto(Long orderId, BigDecimal total, String summary) {
|
||||
|
||||
public static Builder builder() {
|
||||
return new Builder();
|
||||
}
|
||||
|
||||
public static final class Builder {
|
||||
private Long orderId;
|
||||
private BigDecimal total;
|
||||
private String summary;
|
||||
|
||||
public Builder orderId(Long orderId) { this.orderId = orderId; return this; }
|
||||
public Builder total(BigDecimal total) { this.total = total; return this; }
|
||||
public Builder summary(String summary) { this.summary = summary; return this; }
|
||||
public ReceiptDto build() { return new ReceiptDto(orderId, total, summary); }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package com.ankurm.mapstruct.mapper;
|
||||
|
||||
import com.ankurm.mapstruct.domain.Address;
|
||||
import com.ankurm.mapstruct.dto.AddressDto;
|
||||
import org.mapstruct.BeanMapping;
|
||||
import org.mapstruct.InheritInverseConfiguration;
|
||||
import org.mapstruct.Mapper;
|
||||
import org.mapstruct.Mapping;
|
||||
import org.mapstruct.MappingTarget;
|
||||
import org.mapstruct.NullValuePropertyMappingStrategy;
|
||||
|
||||
@Mapper(config = CentralConfig.class)
|
||||
public interface AddressMapper {
|
||||
|
||||
@Mapping(target = "zip", source = "postalCode")
|
||||
@Mapping(target = "country", source = "country", defaultValue = "IN")
|
||||
AddressDto toDto(Address address);
|
||||
|
||||
/** The rename is declared once, above; this method inherits it in reverse. */
|
||||
@InheritInverseConfiguration(name = "toDto")
|
||||
Address toEntity(AddressDto dto);
|
||||
|
||||
/** Field-by-field patch of an existing Address: a null component leaves the existing value alone. */
|
||||
@Mapping(target = "postalCode", source = "zip")
|
||||
@BeanMapping(nullValuePropertyMappingStrategy = NullValuePropertyMappingStrategy.IGNORE)
|
||||
void update(AddressDto dto, @MappingTarget Address address);
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package com.ankurm.mapstruct.mapper;
|
||||
|
||||
import org.mapstruct.InjectionStrategy;
|
||||
import org.mapstruct.MapperConfig;
|
||||
import org.mapstruct.MappingConstants;
|
||||
import org.mapstruct.ReportingPolicy;
|
||||
|
||||
/**
|
||||
* One place for the three decisions every mapper in this module shares:
|
||||
* Spring beans, constructor injection, and a compile error for any target property nobody mapped.
|
||||
*/
|
||||
@MapperConfig(
|
||||
componentModel = MappingConstants.ComponentModel.SPRING,
|
||||
injectionStrategy = InjectionStrategy.CONSTRUCTOR,
|
||||
unmappedTargetPolicy = ReportingPolicy.ERROR)
|
||||
public interface CentralConfig {
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
package com.ankurm.mapstruct.mapper;
|
||||
|
||||
import com.ankurm.mapstruct.domain.Customer;
|
||||
import com.ankurm.mapstruct.dto.CustomerDto;
|
||||
import com.ankurm.mapstruct.dto.CustomerPatch;
|
||||
import com.ankurm.mapstruct.dto.CustomerRequest;
|
||||
import java.time.Clock;
|
||||
import java.time.LocalDate;
|
||||
import java.time.Period;
|
||||
import java.util.Locale;
|
||||
import org.mapstruct.AfterMapping;
|
||||
import org.mapstruct.BeanMapping;
|
||||
import org.mapstruct.Context;
|
||||
import org.mapstruct.Mapper;
|
||||
import org.mapstruct.Mapping;
|
||||
import org.mapstruct.MappingTarget;
|
||||
import org.mapstruct.Named;
|
||||
import org.mapstruct.NullValueCheckStrategy;
|
||||
import org.mapstruct.NullValuePropertyMappingStrategy;
|
||||
|
||||
@Mapper(config = CentralConfig.class, uses = {AddressMapper.class, OrderMapper.class})
|
||||
public interface CustomerMapper {
|
||||
|
||||
// ---- entity -> record -------------------------------------------------------------------------------
|
||||
|
||||
@Mapping(target = "fullName", expression = "java(customer.getFirstName() + \" \" + customer.getLastName())")
|
||||
@Mapping(target = "age", source = "birthDate", qualifiedByName = "ageInYears")
|
||||
@Mapping(target = "nickname", source = "nickname", defaultValue = "anonymous")
|
||||
@Mapping(target = "origin", constant = "api")
|
||||
CustomerDto toDto(Customer customer, @Context Clock clock);
|
||||
|
||||
@Named("ageInYears")
|
||||
default int ageInYears(LocalDate birthDate, @Context Clock clock) {
|
||||
return birthDate == null ? 0 : Period.between(birthDate, LocalDate.now(clock)).getYears();
|
||||
}
|
||||
|
||||
// ---- record -> entity (create) ----------------------------------------------------------------------
|
||||
|
||||
@Mapping(target = "id", ignore = true)
|
||||
@Mapping(target = "passwordHash", ignore = true)
|
||||
@Mapping(target = "attributes", ignore = true)
|
||||
@Mapping(target = "orders", ignore = true)
|
||||
@Mapping(target = "createdAt", ignore = true)
|
||||
@Mapping(target = "updatedAt", ignore = true)
|
||||
Customer toEntity(CustomerRequest request, @Context Clock clock);
|
||||
|
||||
/** Same mapping, but a null source property never overwrites what the entity already holds. */
|
||||
@BeanMapping(nullValueCheckStrategy = NullValueCheckStrategy.ALWAYS)
|
||||
@Mapping(target = "id", ignore = true)
|
||||
@Mapping(target = "passwordHash", ignore = true)
|
||||
@Mapping(target = "attributes", ignore = true)
|
||||
@Mapping(target = "orders", ignore = true)
|
||||
@Mapping(target = "createdAt", ignore = true)
|
||||
@Mapping(target = "updatedAt", ignore = true)
|
||||
Customer toEntityKeepingDefaults(CustomerRequest request, @Context Clock clock);
|
||||
|
||||
/** Runs after every method that produces a Customer: normalises the e-mail and stamps the creation time. */
|
||||
@AfterMapping
|
||||
default void stamp(@MappingTarget Customer customer, @Context Clock clock) {
|
||||
if (customer.getEmail() != null) {
|
||||
customer.setEmail(customer.getEmail().trim().toLowerCase(Locale.ROOT));
|
||||
}
|
||||
customer.setCreatedAt(clock.instant());
|
||||
}
|
||||
|
||||
// ---- record -> existing entity (patch) --------------------------------------------------------------
|
||||
|
||||
@BeanMapping(ignoreByDefault = true, nullValuePropertyMappingStrategy = NullValuePropertyMappingStrategy.IGNORE)
|
||||
@Mapping(target = "email", source = "email")
|
||||
@Mapping(target = "nickname", source = "nickname")
|
||||
@Mapping(target = "address", source = "address")
|
||||
@Mapping(target = "roles", source = "roles")
|
||||
void patch(CustomerPatch patch, @MappingTarget Customer customer);
|
||||
|
||||
/** The same patch without the IGNORE strategy: MapStruct's default, where a null component overwrites. */
|
||||
@BeanMapping(ignoreByDefault = true)
|
||||
@Mapping(target = "email", source = "email")
|
||||
@Mapping(target = "nickname", source = "nickname")
|
||||
@Mapping(target = "address", source = "address")
|
||||
@Mapping(target = "roles", source = "roles")
|
||||
void replace(CustomerPatch patch, @MappingTarget Customer customer);
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package com.ankurm.mapstruct.mapper;
|
||||
|
||||
import com.ankurm.mapstruct.domain.Order;
|
||||
import com.ankurm.mapstruct.dto.InvoiceDto;
|
||||
import org.mapstruct.DecoratedWith;
|
||||
import org.mapstruct.Mapper;
|
||||
import org.mapstruct.Mapping;
|
||||
|
||||
/** Post-processing a record target by wrapping the generated mapper in a decorator. */
|
||||
@Mapper(config = CentralConfig.class, uses = OrderMapper.class)
|
||||
@DecoratedWith(InvoiceMapperDecorator.class)
|
||||
public interface InvoiceMapper {
|
||||
|
||||
@Mapping(target = "total", source = "lines", qualifiedByName = "sumOfLines")
|
||||
@Mapping(target = "display", ignore = true)
|
||||
InvoiceDto toInvoice(Order order);
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package com.ankurm.mapstruct.mapper;
|
||||
|
||||
import com.ankurm.mapstruct.domain.Order;
|
||||
import com.ankurm.mapstruct.dto.InvoiceDto;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.beans.factory.annotation.Qualifier;
|
||||
|
||||
/**
|
||||
* The generated InvoiceMapperImpl extends this class. The generated delegate is a second bean, InvoiceMapperImpl_,
|
||||
* qualified "delegate". MapStruct 1.6.3 generates the subclass without a constructor, so this class needs a no-arg
|
||||
* constructor and the delegate has to arrive by setter (or field) injection, not constructor injection.
|
||||
* To "change" an immutable record the decorator has to build a new one.
|
||||
*/
|
||||
public abstract class InvoiceMapperDecorator implements InvoiceMapper {
|
||||
|
||||
private InvoiceMapper delegate;
|
||||
|
||||
@Autowired
|
||||
public void setDelegate(@Qualifier("delegate") InvoiceMapper delegate) {
|
||||
this.delegate = delegate;
|
||||
}
|
||||
|
||||
@Override
|
||||
public InvoiceDto toInvoice(Order order) {
|
||||
InvoiceDto base = delegate.toInvoice(order);
|
||||
if (base == null) {
|
||||
return null;
|
||||
}
|
||||
return new InvoiceDto(base.id(), base.status(), base.total(),
|
||||
"#" + base.id() + " [" + base.status() + "] " + base.total());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package com.ankurm.mapstruct.mapper;
|
||||
|
||||
import com.ankurm.mapstruct.domain.Order;
|
||||
import com.ankurm.mapstruct.dto.OrderDto;
|
||||
import org.mapstruct.Mapper;
|
||||
import org.mapstruct.Mapping;
|
||||
import org.mapstruct.NullValueMappingStrategy;
|
||||
|
||||
/**
|
||||
* The same Order -> OrderDto mapping as OrderMapper, but a null Order, a null list of lines and a null map
|
||||
* come out as a default object, an empty list and an empty map instead of null.
|
||||
*/
|
||||
@Mapper(config = CentralConfig.class, uses = OrderMapper.class,
|
||||
nullValueMappingStrategy = NullValueMappingStrategy.RETURN_DEFAULT,
|
||||
nullValueIterableMappingStrategy = NullValueMappingStrategy.RETURN_DEFAULT,
|
||||
nullValueMapMappingStrategy = NullValueMappingStrategy.RETURN_DEFAULT)
|
||||
public interface LenientOrderMapper {
|
||||
|
||||
@Mapping(target = "total", source = "lines", qualifiedByName = "sumOfLines")
|
||||
OrderDto toDto(Order order);
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package com.ankurm.mapstruct.mapper;
|
||||
|
||||
import com.ankurm.mapstruct.domain.Order;
|
||||
import com.ankurm.mapstruct.domain.OrderLine;
|
||||
import com.ankurm.mapstruct.dto.OrderDto;
|
||||
import com.ankurm.mapstruct.dto.OrderLineDto;
|
||||
import java.math.BigDecimal;
|
||||
import java.util.List;
|
||||
import org.mapstruct.Mapper;
|
||||
import org.mapstruct.Mapping;
|
||||
import org.mapstruct.Named;
|
||||
|
||||
@Mapper(config = CentralConfig.class)
|
||||
public interface OrderMapper {
|
||||
|
||||
@Mapping(target = "lineTotal",
|
||||
expression = "java(line.getUnitPrice().multiply(java.math.BigDecimal.valueOf(line.getQuantity())))")
|
||||
OrderLineDto toDto(OrderLine line);
|
||||
|
||||
@Mapping(target = "total", source = "lines", qualifiedByName = "sumOfLines")
|
||||
OrderDto toDto(Order order);
|
||||
|
||||
/** MapStruct writes the loop; this is only here so the List and the element mapping have a name. */
|
||||
List<OrderDto> toDtos(List<Order> orders);
|
||||
|
||||
/** Not used by any implicit mapping: only a property that names it in qualifiedByName gets it. */
|
||||
@Named("sumOfLines")
|
||||
default BigDecimal sumOfLines(List<OrderLine> lines) {
|
||||
if (lines == null) {
|
||||
return BigDecimal.ZERO;
|
||||
}
|
||||
return lines.stream()
|
||||
.map(l -> l.getUnitPrice().multiply(BigDecimal.valueOf(l.getQuantity())))
|
||||
.reduce(BigDecimal.ZERO, BigDecimal::add);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package com.ankurm.mapstruct.mapper;
|
||||
|
||||
import com.ankurm.mapstruct.domain.Order;
|
||||
import com.ankurm.mapstruct.dto.ReceiptDto;
|
||||
import org.mapstruct.AfterMapping;
|
||||
import org.mapstruct.Mapper;
|
||||
import org.mapstruct.Mapping;
|
||||
import org.mapstruct.MappingTarget;
|
||||
|
||||
/** Post-processing a record target through its builder. */
|
||||
@Mapper(config = CentralConfig.class, uses = OrderMapper.class)
|
||||
public interface ReceiptMapper {
|
||||
|
||||
@Mapping(target = "orderId", source = "id")
|
||||
@Mapping(target = "total", source = "lines", qualifiedByName = "sumOfLines")
|
||||
@Mapping(target = "summary", ignore = true)
|
||||
ReceiptDto toReceipt(Order order);
|
||||
|
||||
/** The hook gets the builder. Once build() has run, the record can no longer be touched. */
|
||||
@AfterMapping
|
||||
default void summarise(Order order, @MappingTarget ReceiptDto.Builder builder) {
|
||||
builder.summary("order " + order.getId() + ": " + order.getLines().size() + " line(s)");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package com.ankurm.mapstruct.web;
|
||||
|
||||
import com.ankurm.mapstruct.dto.CustomerDto;
|
||||
import com.ankurm.mapstruct.dto.CustomerPatch;
|
||||
import com.ankurm.mapstruct.dto.CustomerRequest;
|
||||
import com.ankurm.mapstruct.mapper.CustomerMapper;
|
||||
import java.time.Clock;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PatchMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.ResponseStatus;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/customers")
|
||||
public class CustomerController {
|
||||
|
||||
private final CustomerRepository repository;
|
||||
private final CustomerMapper mapper;
|
||||
private final Clock clock;
|
||||
|
||||
public CustomerController(CustomerRepository repository, CustomerMapper mapper, Clock clock) {
|
||||
this.repository = repository;
|
||||
this.mapper = mapper;
|
||||
this.clock = clock;
|
||||
}
|
||||
|
||||
@GetMapping("/{id}")
|
||||
public CustomerDto get(@PathVariable Long id) {
|
||||
return repository.findById(id).map(c -> mapper.toDto(c, clock)).orElseThrow(
|
||||
() -> new ResponseStatusException(HttpStatus.NOT_FOUND));
|
||||
}
|
||||
|
||||
@PostMapping
|
||||
@ResponseStatus(HttpStatus.CREATED)
|
||||
public CustomerDto create(@RequestBody CustomerRequest request) {
|
||||
return mapper.toDto(repository.save(mapper.toEntity(request, clock)), clock);
|
||||
}
|
||||
|
||||
@PatchMapping("/{id}")
|
||||
public CustomerDto patch(@PathVariable Long id, @RequestBody CustomerPatch patch) {
|
||||
var customer = repository.findById(id).orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND));
|
||||
mapper.patch(patch, customer);
|
||||
return mapper.toDto(repository.save(customer), clock);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
package com.ankurm.mapstruct.web;
|
||||
|
||||
import com.ankurm.mapstruct.domain.Address;
|
||||
import com.ankurm.mapstruct.domain.Customer;
|
||||
import com.ankurm.mapstruct.domain.Order;
|
||||
import com.ankurm.mapstruct.domain.OrderLine;
|
||||
import com.ankurm.mapstruct.domain.OrderStatus;
|
||||
import java.math.BigDecimal;
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDate;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.atomic.AtomicLong;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
/** An in-memory stand-in for a JPA repository, seeded with one customer. */
|
||||
@Repository
|
||||
public class CustomerRepository {
|
||||
|
||||
private final Map<Long, Customer> store = new ConcurrentHashMap<>();
|
||||
private final AtomicLong ids = new AtomicLong();
|
||||
|
||||
public CustomerRepository() {
|
||||
save(sample());
|
||||
}
|
||||
|
||||
public Optional<Customer> findById(Long id) {
|
||||
return Optional.ofNullable(store.get(id));
|
||||
}
|
||||
|
||||
public Customer save(Customer customer) {
|
||||
if (customer.getId() == null) {
|
||||
customer.setId(ids.incrementAndGet());
|
||||
}
|
||||
store.put(customer.getId(), customer);
|
||||
return customer;
|
||||
}
|
||||
|
||||
/** The customer every test and every transcript starts from. */
|
||||
public static Customer sample() {
|
||||
Customer c = new Customer();
|
||||
c.setFirstName("Asha");
|
||||
c.setLastName("Mehta");
|
||||
c.setEmail("[email protected]");
|
||||
c.setPasswordHash("{bcrypt}$2a$10$never-leaves-the-server");
|
||||
c.setBirthDate(LocalDate.of(1990, 6, 15));
|
||||
c.setNickname(null);
|
||||
c.setAddress(new Address("12 Park Road", "Pune", "411001", null));
|
||||
c.setRoles(new java.util.LinkedHashSet<>(Set.of("USER")));
|
||||
c.getAttributes().put("newsletter", "yes");
|
||||
c.setCreatedAt(Instant.parse("2026-01-01T00:00:00Z"));
|
||||
c.setOrders(List.of(new Order(100L, OrderStatus.PAID, Instant.parse("2026-02-01T10:00:00Z"), List.of(
|
||||
new OrderLine("BOOK-1", 2, new BigDecimal("12.50")),
|
||||
new OrderLine("PEN-9", 10, new BigDecimal("0.75"))))));
|
||||
return c;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
package com.ankurm.mapstruct;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
import com.ankurm.mapstruct.domain.Customer;
|
||||
import com.ankurm.mapstruct.probe.HandWritten;
|
||||
import com.ankurm.mapstruct.probe.ViewMapper;
|
||||
import com.ankurm.mapstruct.probe.ViewMapperImpl;
|
||||
import com.ankurm.mapstruct.probe.Views.CustomerView;
|
||||
import com.github.dozermapper.core.DozerBeanMapperBuilder;
|
||||
import com.github.dozermapper.core.Mapper;
|
||||
import java.util.List;
|
||||
import java.util.function.Function;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
/**
|
||||
* An INDICATIVE comparison: a hand-rolled warm-up, a few timed rounds, one JVM, one machine. It is not JMH.
|
||||
* The numbers move from run to run; what is stable is the order of magnitude, so the test only asserts
|
||||
* that all three mappers produce the same result.
|
||||
*/
|
||||
class BenchmarkTests {
|
||||
|
||||
private static volatile long sink;
|
||||
|
||||
private static double nsPerOp(Function<Customer, CustomerView> f, Customer c, int iterations) {
|
||||
long start = System.nanoTime();
|
||||
long acc = 0;
|
||||
for (int i = 0; i < iterations; i++) {
|
||||
acc += f.apply(c).getOrders().size();
|
||||
}
|
||||
sink += acc;
|
||||
return (System.nanoTime() - start) / (double) iterations;
|
||||
}
|
||||
|
||||
@Test
|
||||
void handWrittenMapStructAndDozer() {
|
||||
try (var t = new Transcript("16-indicative-timing-vs-handwritten-and-dozer.txt",
|
||||
"INDICATIVE timings: hand-written vs MapStruct vs Dozer (setter-based beans)")) {
|
||||
Customer c = Fixtures.customer();
|
||||
ViewMapper mapStruct = new ViewMapperImpl();
|
||||
Mapper dozer = DozerBeanMapperBuilder.buildDefault();
|
||||
|
||||
long first = System.nanoTime();
|
||||
CustomerView viaDozer = dozer.map(c, CustomerView.class);
|
||||
double dozerFirstMs = (System.nanoTime() - first) / 1e6;
|
||||
first = System.nanoTime();
|
||||
CustomerView viaMapStruct = mapStruct.toView(c);
|
||||
double mapStructFirstMs = (System.nanoTime() - first) / 1e6;
|
||||
CustomerView viaHand = HandWritten.toView(c);
|
||||
|
||||
Function<Customer, CustomerView> hand = HandWritten::toView;
|
||||
Function<Customer, CustomerView> ms = mapStruct::toView;
|
||||
Function<Customer, CustomerView> dz = x -> dozer.map(x, CustomerView.class);
|
||||
|
||||
for (var f : List.of(hand, ms, dz)) {
|
||||
nsPerOp(f, c, 50_000); // warm-up
|
||||
}
|
||||
int n = 200_000;
|
||||
double h = Double.MAX_VALUE, m = Double.MAX_VALUE, d = Double.MAX_VALUE;
|
||||
for (int round = 0; round < 5; round++) {
|
||||
h = Math.min(h, nsPerOp(hand, c, n));
|
||||
m = Math.min(m, nsPerOp(ms, c, n));
|
||||
d = Math.min(d, nsPerOp(dz, c, n));
|
||||
}
|
||||
boolean equal = sameShape(viaHand, viaMapStruct) && sameShape(viaHand, viaDozer);
|
||||
|
||||
t.line("Java %s, one JVM, %d iterations per round, best of 5 rounds after a 50000-iteration warm-up", Runtime.version(), n);
|
||||
t.line("mapping: Customer -> CustomerView (1 address, 1 order with 2 lines, 1 set, 1 map)");
|
||||
t.blank();
|
||||
t.line("%-16s %10.0f ns/op", "hand-written", h);
|
||||
t.line("%-16s %10.0f ns/op (%.1fx hand-written)", "MapStruct 1.6.3", m, m / h);
|
||||
t.line("%-16s %10.0f ns/op (%.0fx hand-written)", "Dozer 7.0.0", d, d / h);
|
||||
t.blank();
|
||||
t.line("first call, cold: MapStruct %.2f ms, Dozer %.1f ms", mapStructFirstMs, dozerFirstMs);
|
||||
t.line("results equal: %s", equal);
|
||||
t.blank();
|
||||
t.line("Indicative only. Timings vary between runs and machines; do not quote them as a benchmark result.");
|
||||
|
||||
assertThat(equal).isTrue();
|
||||
assertThat(sink).isPositive();
|
||||
}
|
||||
}
|
||||
|
||||
private static boolean sameShape(CustomerView a, CustomerView b) {
|
||||
var la = a.getOrders().get(0).getLines().get(1);
|
||||
var lb = b.getOrders().get(0).getLines().get(1);
|
||||
return a.getFirstName().equals(b.getFirstName()) && a.getTier().equals(b.getTier())
|
||||
&& a.getAddress().getPostalCode().equals(b.getAddress().getPostalCode())
|
||||
&& a.getRoles().equals(b.getRoles()) && a.getAttributes().equals(b.getAttributes())
|
||||
&& a.getOrders().get(0).getStatus().equals(b.getOrders().get(0).getStatus())
|
||||
&& la.getUnitPrice().compareTo(lb.getUnitPrice()) == 0 && la.getQuantity() == lb.getQuantity();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
package com.ankurm.mapstruct;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
import com.ankurm.mapstruct.mapper.AddressMapperImpl;
|
||||
import com.ankurm.mapstruct.mapper.CustomerMapper;
|
||||
import com.ankurm.mapstruct.mapper.CustomerMapperImpl;
|
||||
import com.ankurm.mapstruct.mapper.OrderMapperImpl;
|
||||
import java.time.Clock;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.context.ConfigurableApplicationContext;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.test.context.junit.jupiter.SpringJUnitConfig;
|
||||
|
||||
/** @SpringJUnitConfig is @ContextConfiguration + the JUnit extension: a context made of just the generated classes. */
|
||||
@SpringJUnitConfig(classes = {CustomerMapperImpl.class, AddressMapperImpl.class, OrderMapperImpl.class,
|
||||
ContextConfigurationMapperTest.PinnedClock.class})
|
||||
class ContextConfigurationMapperTest {
|
||||
|
||||
@Configuration
|
||||
static class PinnedClock {
|
||||
@Bean
|
||||
Clock clock() {
|
||||
return Fixtures.clock();
|
||||
}
|
||||
}
|
||||
|
||||
@Autowired
|
||||
ConfigurableApplicationContext context;
|
||||
|
||||
@Autowired
|
||||
CustomerMapper mapper;
|
||||
|
||||
@Autowired
|
||||
Clock clock;
|
||||
|
||||
@Test
|
||||
void aContextOfOnlyTheGeneratedClasses() {
|
||||
try (var t = new Transcript("12-testing-mappers-contextconfiguration.txt",
|
||||
"@SpringJUnitConfig(classes = {the generated impls}): only the beans the mapper needs")) {
|
||||
int definitions = context.getBeanDefinitionCount();
|
||||
String name = mapper.toDto(Fixtures.customer(), clock).fullName();
|
||||
t.line("bean definitions in the context : %d", definitions);
|
||||
t.line("bean names : %s", String.join(", ", context.getBeanDefinitionNames()));
|
||||
t.line("mapper injected as : %s", mapper.getClass().getSimpleName());
|
||||
t.line("mapped : %s", name);
|
||||
|
||||
assertThat(definitions).isLessThan(20);
|
||||
assertThat(mapper).isInstanceOf(CustomerMapperImpl.class);
|
||||
assertThat(name).isEqualTo("Asha Mehta");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
package com.ankurm.mapstruct;
|
||||
|
||||
import com.ankurm.mapstruct.domain.Customer;
|
||||
import com.ankurm.mapstruct.mapper.AddressMapperImpl;
|
||||
import com.ankurm.mapstruct.mapper.CustomerMapperImpl;
|
||||
import com.ankurm.mapstruct.mapper.OrderMapperImpl;
|
||||
import com.ankurm.mapstruct.web.CustomerRepository;
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.time.Clock;
|
||||
import java.time.Instant;
|
||||
import java.time.ZoneOffset;
|
||||
|
||||
/** Shared test data and two helpers: a pinned clock, and a reader for the sources MapStruct generated. */
|
||||
public final class Fixtures {
|
||||
|
||||
private Fixtures() { }
|
||||
|
||||
/** 2026-11-05T00:00:00Z, so ages and creation timestamps are the same on every run. */
|
||||
public static Clock clock() {
|
||||
return Clock.fixed(Instant.parse("2026-11-05T00:00:00Z"), ZoneOffset.UTC);
|
||||
}
|
||||
|
||||
public static Customer customer() {
|
||||
return CustomerRepository.sample();
|
||||
}
|
||||
|
||||
/** What Spring would do with constructor injection, without Spring. */
|
||||
public static CustomerMapperImpl customerMapper() {
|
||||
return new CustomerMapperImpl(new AddressMapperImpl(), new OrderMapperImpl());
|
||||
}
|
||||
|
||||
/**
|
||||
* The generated source for a mapper, trimmed to the class itself: no package, imports or the
|
||||
* {@code @Generated} annotation (whose date changes on every build).
|
||||
*/
|
||||
public static String generated(String root, String fqcn) {
|
||||
Path file = Path.of("target", root, fqcn.replace('.', '/') + ".java");
|
||||
try {
|
||||
String src = Files.readString(file).replace("\r\n", "\n");
|
||||
int start = src.indexOf("@Component");
|
||||
if (start < 0) {
|
||||
start = src.indexOf("public class");
|
||||
}
|
||||
return src.substring(start);
|
||||
} catch (IOException e) {
|
||||
throw new IllegalStateException("no generated source at " + file, e);
|
||||
}
|
||||
}
|
||||
|
||||
public static String generatedMain(String fqcn) {
|
||||
return generated("generated-sources/annotations", fqcn);
|
||||
}
|
||||
|
||||
public static String generatedTest(String fqcn) {
|
||||
return generated("generated-test-sources/test-annotations", fqcn);
|
||||
}
|
||||
|
||||
/** One method out of generated source: its signature line down to the closing brace at four spaces of indent. */
|
||||
public static String method(String source, String signatureStart) {
|
||||
int i = source.indexOf(signatureStart);
|
||||
if (i < 0) {
|
||||
throw new IllegalStateException("no method starting with " + signatureStart);
|
||||
}
|
||||
int lineStart = source.lastIndexOf('\n', i) + 1;
|
||||
int end = source.indexOf("\n }\n", i);
|
||||
return source.substring(lineStart, end + "\n }".length());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
package com.ankurm.mapstruct;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
import com.ankurm.mapstruct.domain.Order;
|
||||
import com.ankurm.mapstruct.dto.InvoiceDto;
|
||||
import com.ankurm.mapstruct.dto.ReceiptDto;
|
||||
import com.ankurm.mapstruct.mapper.InvoiceMapper;
|
||||
import com.ankurm.mapstruct.mapper.InvoiceMapperImpl;
|
||||
import com.ankurm.mapstruct.mapper.InvoiceMapperImpl_;
|
||||
import com.ankurm.mapstruct.mapper.OrderMapperImpl;
|
||||
import com.ankurm.mapstruct.mapper.ReceiptMapperImpl;
|
||||
import com.ankurm.mapstruct.probe.Probes;
|
||||
import com.ankurm.mapstruct.probe.CycleContext;
|
||||
import com.ankurm.mapstruct.probe.CycleMapperImpl;
|
||||
import com.ankurm.mapstruct.probe.FactoryTrapMapperImpl;
|
||||
import com.ankurm.mapstruct.probe.HookOrderMapperImpl;
|
||||
import com.ankurm.mapstruct.probe.Probes.Employee;
|
||||
import java.util.Arrays;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.context.ApplicationContext;
|
||||
import org.springframework.test.context.junit.jupiter.SpringJUnitConfig;
|
||||
|
||||
/** @BeforeMapping, @AfterMapping, @ObjectFactory, builders, decorators and @Context - and what records change. */
|
||||
@SpringJUnitConfig(classes = {InvoiceMapperImpl.class, InvoiceMapperImpl_.class, OrderMapperImpl.class})
|
||||
class HooksTests {
|
||||
|
||||
@Autowired
|
||||
ApplicationContext context;
|
||||
|
||||
@Autowired
|
||||
InvoiceMapper invoiceMapper;
|
||||
|
||||
@BeforeEach
|
||||
void clear() {
|
||||
Probes.TRACE.clear();
|
||||
}
|
||||
|
||||
@Test
|
||||
void beforeMappingWithAMappingTargetRunsAfterTheRecordIsBuilt() {
|
||||
try (var t = new Transcript("07-hooks-on-records.txt",
|
||||
"@BeforeMapping / @AfterMapping on a record target versus a bean target (call order, recorded)")) {
|
||||
var mapper = new HookOrderMapperImpl();
|
||||
|
||||
mapper.toRecord(new Probes.Pair("pen", 3));
|
||||
t.section("target is a record: toRecord(Pair)");
|
||||
Probes.TRACE.forEach(t::line);
|
||||
var recordTrace = java.util.List.copyOf(Probes.TRACE);
|
||||
|
||||
Probes.TRACE.clear();
|
||||
mapper.toBean(new Probes.Pair("pen", 3));
|
||||
t.section("target is a mutable bean: toBean(Pair)");
|
||||
Probes.TRACE.forEach(t::line);
|
||||
var beanTrace = java.util.List.copyOf(Probes.TRACE);
|
||||
|
||||
String generated = Fixtures.generatedTest("com.ankurm.mapstruct.probe.HookOrderMapperImpl");
|
||||
t.section("the generated toRecord, which is why");
|
||||
t.block(Fixtures.method(generated, "toRecord("));
|
||||
|
||||
assertThat(recordTrace).containsExactly(
|
||||
"@BeforeMapping(source)",
|
||||
"@BeforeMapping(source, @MappingTarget Boxed) target=Boxed[name=pen, qty=3]",
|
||||
"@AfterMapping(source, @MappingTarget Boxed) target=Boxed[name=pen, qty=3]",
|
||||
"@AfterMapping(source)");
|
||||
assertThat(beanTrace).containsExactly(
|
||||
"@BeforeMapping(source)",
|
||||
"@BeforeMapping(source, @MappingTarget BoxedBean) name=null",
|
||||
" BoxedBean.setName(pen)",
|
||||
" BoxedBean.setQty(3)",
|
||||
"@AfterMapping(source, @MappingTarget BoxedBean) name=pen",
|
||||
"@AfterMapping(source)");
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void anObjectFactoryThatReturnsARecordSilentlyDropsEveryProperty() {
|
||||
try (var t = new Transcript("08-object-factory-trap.txt",
|
||||
"@ObjectFactory returning a record: the factory's object is the result, and nothing is copied into it")) {
|
||||
var mapper = new FactoryTrapMapperImpl();
|
||||
var result = mapper.toRecord(new Probes.Pair("pen", 3));
|
||||
t.line("source : %s", new Probes.Pair("pen", 3));
|
||||
t.line("result : %s", result);
|
||||
t.line("compiled with unmappedTargetPolicy = ERROR, and no error was reported");
|
||||
t.section("the generated method");
|
||||
t.block(Fixtures.method(Fixtures.generatedTest("com.ankurm.mapstruct.probe.FactoryTrapMapperImpl"),
|
||||
"toRecord("));
|
||||
|
||||
assertThat(result).isEqualTo(new Probes.Boxed("from-the-factory", 0));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void aBuilderIsTheWayToPostProcessARecord() {
|
||||
try (var t = new Transcript("09-builder-and-decorator.txt",
|
||||
"Post-processing a record: @AfterMapping on its builder, or a @DecoratedWith decorator")) {
|
||||
Order order = Fixtures.customer().getOrders().get(0);
|
||||
|
||||
var receiptMapper = new ReceiptMapperImpl(new OrderMapperImpl());
|
||||
ReceiptDto receipt = receiptMapper.toReceipt(order);
|
||||
t.section("ReceiptMapper: the hook receives ReceiptDto.Builder");
|
||||
t.line("%s", receipt);
|
||||
t.block(Fixtures.method(Fixtures.generatedMain("com.ankurm.mapstruct.mapper.ReceiptMapperImpl"),
|
||||
"public ReceiptDto toReceipt("));
|
||||
|
||||
InvoiceDto invoice = invoiceMapper.toInvoice(order);
|
||||
InvoiceDto raw = context.getBean(InvoiceMapperImpl_.class).toInvoice(order);
|
||||
t.section("InvoiceMapper: @DecoratedWith, so two beans implement the interface");
|
||||
for (String name : context.getBeanNamesForType(InvoiceMapper.class)) {
|
||||
t.line("bean %-20s -> %s", name, context.getBean(name).getClass().getSimpleName());
|
||||
}
|
||||
t.line("injected InvoiceMapper (the @Primary one) : %s", invoice);
|
||||
t.line("the generated delegate on its own : %s", raw);
|
||||
t.section("the generated class that decorates");
|
||||
t.block(Fixtures.generatedMain("com.ankurm.mapstruct.mapper.InvoiceMapperImpl").stripTrailing());
|
||||
|
||||
assertThat(receipt.summary()).isEqualTo("order 100: 2 line(s)");
|
||||
assertThat(receipt.orderId()).isEqualTo(100L);
|
||||
assertThat(context.getBeanNamesForType(InvoiceMapper.class)).hasSize(2);
|
||||
assertThat(invoiceMapper).isInstanceOf(InvoiceMapperImpl.class);
|
||||
assertThat(invoice.display()).isEqualTo("#100 [PAID] 32.50");
|
||||
assertThat(raw.display()).isNull();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void recordsCannotRepresentACycleEvenWithTheContextRecipe() {
|
||||
try (var t = new Transcript("10-context-and-cycles.txt",
|
||||
"@Context cycle tracking: it works for a bean target and cannot work for a record target")) {
|
||||
Employee ann = new Employee("Ann");
|
||||
Employee bob = new Employee("Bob");
|
||||
ann.manager = bob;
|
||||
bob.manager = ann;
|
||||
var mapper = new CycleMapperImpl();
|
||||
|
||||
var bean = mapper.toBean(ann, new CycleContext());
|
||||
t.line("bean target + @Context CycleContext : ann.manager.manager is ann? %s", bean.getManager().getManager() == bean);
|
||||
|
||||
Throwable thrown = null;
|
||||
try {
|
||||
mapper.toRecord(ann, new CycleContext());
|
||||
} catch (StackOverflowError e) {
|
||||
thrown = e;
|
||||
}
|
||||
t.line("record target + the same @Context : %s", thrown == null ? "mapped" : thrown.getClass().getName());
|
||||
|
||||
t.section("why: the generated record mapping recurses BEFORE it can call the constructor");
|
||||
t.block(Fixtures.method(Fixtures.generatedTest("com.ankurm.mapstruct.probe.CycleMapperImpl"),
|
||||
"toRecord("));
|
||||
|
||||
assertThat(bean.getManager().getManager()).isSameAs(bean);
|
||||
assertThat(thrown).isInstanceOf(StackOverflowError.class);
|
||||
assertThatThrownBy(() -> mapper.toRecord(ann, new CycleContext())).isInstanceOf(StackOverflowError.class);
|
||||
assertThat(Arrays.stream(Probes.EmployeeRecord.class.getRecordComponents()).count()).isEqualTo(2);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
package com.ankurm.mapstruct;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
import com.ankurm.mapstruct.domain.Customer;
|
||||
import com.ankurm.mapstruct.dto.AddressDto;
|
||||
import com.ankurm.mapstruct.dto.CustomerDto;
|
||||
import com.ankurm.mapstruct.dto.CustomerRequest;
|
||||
import com.ankurm.mapstruct.mapper.AddressMapperImpl;
|
||||
import com.ankurm.mapstruct.mapper.CustomerMapperImpl;
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDate;
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
/** Records as source and target, the generated code, and the @Mapping options that shape a mapping. */
|
||||
class MappingBasicsTests {
|
||||
|
||||
private final CustomerMapperImpl mapper = Fixtures.customerMapper();
|
||||
|
||||
@Test
|
||||
void recordsAreBuiltThroughTheirConstructorAndNeverThroughSetters() {
|
||||
try (var t = new Transcript("01-records-as-source-and-target.txt",
|
||||
"A record target has no setters: MapStruct calls the canonical constructor")) {
|
||||
long setters = Arrays.stream(CustomerDto.class.getDeclaredMethods())
|
||||
.filter(m -> m.getName().startsWith("set")).count();
|
||||
long customerSetters = Arrays.stream(Customer.class.getDeclaredMethods())
|
||||
.filter(m -> m.getName().startsWith("set")).count();
|
||||
t.line("CustomerDto is a record : %s", CustomerDto.class.isRecord());
|
||||
t.line("setters on CustomerDto : %d", setters);
|
||||
t.line("setters on Customer (entity) : %d", customerSetters);
|
||||
|
||||
String source = Fixtures.generatedMain("com.ankurm.mapstruct.mapper.CustomerMapperImpl");
|
||||
String toDto = Fixtures.method(source, "public CustomerDto toDto(");
|
||||
String ctorLine = toDto.lines().filter(l -> l.contains("new CustomerDto(")).findFirst().orElseThrow().strip();
|
||||
t.section("the line the generated toDto uses to build the record");
|
||||
t.line(ctorLine);
|
||||
|
||||
String toEntity = Fixtures.method(source, "public Customer toEntity(");
|
||||
t.section("record as SOURCE: the generated toEntity reads accessors, not getters");
|
||||
toEntity.lines().filter(l -> l.contains("request.")).limit(4).forEach(l -> t.line(l.strip()));
|
||||
|
||||
assertThat(CustomerDto.class.isRecord()).isTrue();
|
||||
assertThat(setters).isZero();
|
||||
assertThat(customerSetters).isGreaterThan(10);
|
||||
assertThat(ctorLine).startsWith("CustomerDto customerDto = new CustomerDto( id, fullName,");
|
||||
assertThat(toEntity).contains("request.firstName()").doesNotContain("getFirstName");
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void theGeneratedImplementationIsPlainJavaYouCanRead() {
|
||||
try (var t = new Transcript("02-generated-implementation.txt",
|
||||
"What MapStruct 1.6.3 generated, read from target/generated-sources (header and imports trimmed)")) {
|
||||
String address = Fixtures.generatedMain("com.ankurm.mapstruct.mapper.AddressMapperImpl");
|
||||
t.section("AddressMapperImpl");
|
||||
t.block(address);
|
||||
String customer = Fixtures.generatedMain("com.ankurm.mapstruct.mapper.CustomerMapperImpl");
|
||||
t.section("CustomerMapperImpl: constructor injection, then toDto");
|
||||
t.block(customer.substring(0, customer.indexOf(" @Override")).stripTrailing());
|
||||
t.blank();
|
||||
t.block(Fixtures.method(customer, "public CustomerDto toDto("));
|
||||
|
||||
assertThat(address).contains("@Component").contains("public class AddressMapperImpl implements AddressMapper");
|
||||
assertThat(customer).contains("@Autowired").contains("public CustomerMapperImpl(AddressMapper addressMapper, OrderMapper orderMapper)");
|
||||
assertThat(customer).doesNotContain("reflect");
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void entityToRecordWithNestedObjectsAndCollections() {
|
||||
try (var t = new Transcript("03-entity-to-record.txt",
|
||||
"Customer (mutable entity) -> CustomerDto (record), with nested Address, a List of Orders, a Set and a Map")) {
|
||||
Customer customer = Fixtures.customer();
|
||||
CustomerDto dto = mapper.toDto(customer, Fixtures.clock());
|
||||
|
||||
t.line("fullName : %s <- expression = java(first + \" \" + last)", dto.fullName());
|
||||
t.line("age : %d <- qualifiedByName, with the clock passed as @Context", dto.age());
|
||||
t.line("nickname : %s <- source was null, defaultValue = \"anonymous\"", dto.nickname());
|
||||
t.line("origin : %s <- constant = \"api\"", dto.origin());
|
||||
t.line("address : %s <- postalCode renamed to zip; null country got defaultValue IN", dto.address());
|
||||
t.line("roles : %s same instance as the entity's? %s", dto.roles(), dto.roles() == customer.getRoles());
|
||||
t.line("attributes : %s same instance as the entity's? %s", dto.attributes(), dto.attributes() == customer.getAttributes());
|
||||
t.section("List<Order> -> List<OrderDto>, with a computed total");
|
||||
var order = dto.orders().get(0);
|
||||
t.line("order id=%d status=%s total=%s", order.id(), order.status(), order.total());
|
||||
order.lines().forEach(l -> t.line(" %s", l));
|
||||
t.section("what is NOT in the record");
|
||||
t.line("passwordHash is a component of CustomerDto? %s", Arrays.stream(CustomerDto.class.getRecordComponents())
|
||||
.anyMatch(c -> c.getName().equals("passwordHash")));
|
||||
|
||||
assertThat(dto.fullName()).isEqualTo("Asha Mehta");
|
||||
assertThat(dto.age()).isEqualTo(36);
|
||||
assertThat(dto.nickname()).isEqualTo("anonymous");
|
||||
assertThat(dto.origin()).isEqualTo("api");
|
||||
assertThat(dto.address()).isEqualTo(new AddressDto("12 Park Road", "Pune", "411001", "IN"));
|
||||
assertThat(dto.roles()).isEqualTo(Set.of("USER")).isNotSameAs(customer.getRoles());
|
||||
assertThat(dto.attributes()).containsEntry("newsletter", "yes").isNotSameAs(customer.getAttributes());
|
||||
assertThat(order.status()).isEqualTo("PAID");
|
||||
assertThat(order.total()).isEqualByComparingTo(new BigDecimal("32.50"));
|
||||
assertThat(order.lines()).extracting("lineTotal").usingComparatorForType(BigDecimal::compareTo, BigDecimal.class)
|
||||
.containsExactly(new BigDecimal("25.00"), new BigDecimal("7.50"));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void recordToEntityAndTheInverseConfiguration() {
|
||||
try (var t = new Transcript("04-record-to-entity.txt",
|
||||
"CustomerRequest (record) -> Customer (entity); AddressMapper.toEntity inherits the rename in reverse")) {
|
||||
var request = new CustomerRequest("Ravi", "Iyer", " [email protected] ", LocalDate.of(1985, 1, 2),
|
||||
null, null, new AddressDto("1 Lake View", "Chennai", "600001", null), Set.of("ADMIN"));
|
||||
Customer c = mapper.toEntity(request, Fixtures.clock());
|
||||
|
||||
t.line("email after @AfterMapping : '%s' (request carried '%s')", c.getEmail(), request.email());
|
||||
t.line("createdAt (from @Context) : %s", c.getCreatedAt());
|
||||
t.line("address.postalCode : %s <- @InheritInverseConfiguration turned zip back into postalCode", c.getAddress().getPostalCode());
|
||||
t.line("address.country : %s <- defaultValue is NOT inverted; toDto gives IN, toEntity does not", c.getAddress().getCountry());
|
||||
t.line("tier : %s <- the entity said STANDARD; the request said null", c.getTier());
|
||||
Customer kept = mapper.toEntityKeepingDefaults(request, Fixtures.clock());
|
||||
t.line("tier with nullValueCheckStrategy = ALWAYS : %s", kept.getTier());
|
||||
t.line("id / passwordHash / orders : %s / %s / %s <- ignore = true", c.getId(), c.getPasswordHash(), c.getOrders());
|
||||
|
||||
t.section("round trip: entity -> record -> entity");
|
||||
Customer original = Fixtures.customer();
|
||||
Customer back = mapper.toEntity(new CustomerRequest(original.getFirstName(), original.getLastName(),
|
||||
original.getEmail(), original.getBirthDate(), original.getNickname(), original.getTier(),
|
||||
new AddressMapperImpl().toDto(original.getAddress()), original.getRoles()), Fixtures.clock());
|
||||
t.line("passwordHash survives the round trip? %s", back.getPasswordHash() != null);
|
||||
t.line("address country survives? %s -> %s", original.getAddress().getCountry(), back.getAddress().getCountry());
|
||||
|
||||
assertThat(c.getEmail()).isEqualTo("[email protected]");
|
||||
assertThat(c.getCreatedAt()).isEqualTo(Fixtures.clock().instant());
|
||||
assertThat(c.getAddress().getPostalCode()).isEqualTo("600001");
|
||||
assertThat(c.getAddress().getCountry()).isNull();
|
||||
assertThat(c.getTier()).isNull();
|
||||
assertThat(kept.getTier()).isEqualTo("STANDARD");
|
||||
assertThat(c.getId()).isNull();
|
||||
assertThat(back.getPasswordHash()).isNull();
|
||||
assertThat(back.getAddress().getCountry()).isEqualTo("IN");
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void generatedAddressInverseHasNoDefaultValue() {
|
||||
String address = Fixtures.generatedMain("com.ankurm.mapstruct.mapper.AddressMapperImpl");
|
||||
assertThat(Fixtures.method(address, "public Address toEntity(")).doesNotContain("\"IN\"");
|
||||
assertThat(Fixtures.method(address, "public AddressDto toDto(")).contains("\"IN\"");
|
||||
}
|
||||
|
||||
@Test
|
||||
void listsAreCopiedNotShared() {
|
||||
Customer customer = Fixtures.customer();
|
||||
CustomerDto dto = mapper.toDto(customer, Fixtures.clock());
|
||||
assertThat(dto.orders()).isNotSameAs(customer.getOrders());
|
||||
List<?> before = List.copyOf(dto.orders());
|
||||
customer.getOrders().get(0).setId(999L);
|
||||
assertThat(dto.orders()).isEqualTo(before);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
package com.ankurm.mapstruct;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
import com.ankurm.mapstruct.domain.Address;
|
||||
import com.ankurm.mapstruct.domain.Customer;
|
||||
import com.ankurm.mapstruct.domain.Order;
|
||||
import com.ankurm.mapstruct.dto.AddressDto;
|
||||
import com.ankurm.mapstruct.dto.CustomerPatch;
|
||||
import com.ankurm.mapstruct.dto.OrderDto;
|
||||
import com.ankurm.mapstruct.mapper.CustomerMapperImpl;
|
||||
import com.ankurm.mapstruct.mapper.LenientOrderMapperImpl;
|
||||
import com.ankurm.mapstruct.mapper.OrderMapperImpl;
|
||||
import java.util.Set;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
/** Null strategies (property level and whole-object level) and what @MappingTarget updates really do. */
|
||||
class NullHandlingAndUpdateTests {
|
||||
|
||||
private final CustomerMapperImpl mapper = Fixtures.customerMapper();
|
||||
|
||||
private static String describe(Customer c) {
|
||||
Address a = c.getAddress();
|
||||
return "email=" + c.getEmail() + ", nickname=" + c.getNickname() + ", roles=" + c.getRoles()
|
||||
+ ", address=" + (a == null ? "null" : a.getStreet() + "/" + a.getCity() + "/" + a.getPostalCode());
|
||||
}
|
||||
|
||||
@Test
|
||||
void nullPropertyStrategyDecidesWhetherAPatchErasesData() {
|
||||
try (var t = new Transcript("05-null-strategies.txt",
|
||||
"Null handling: IGNORE vs the default on a patch, and RETURN_DEFAULT on whole objects and collections")) {
|
||||
var allNull = new CustomerPatch(null, null, null, null);
|
||||
|
||||
Customer ignoring = Fixtures.customer();
|
||||
ignoring.setNickname("ash");
|
||||
t.section("patch(): nullValuePropertyMappingStrategy = IGNORE, applied with a patch whose components are all null");
|
||||
t.line("before: %s", describe(ignoring));
|
||||
mapper.patch(allNull, ignoring);
|
||||
t.line("after : %s", describe(ignoring));
|
||||
|
||||
Customer replacing = Fixtures.customer();
|
||||
replacing.setNickname("ash");
|
||||
t.section("replace(): the default strategy (SET_TO_NULL), same all-null patch");
|
||||
t.line("before: %s", describe(replacing));
|
||||
mapper.replace(allNull, replacing);
|
||||
t.line("after : %s", describe(replacing));
|
||||
|
||||
assertThat(describe(ignoring)).isEqualTo("[email protected], nickname=ash, roles=[USER], address=12 Park Road/Pune/411001");
|
||||
assertThat(replacing.getEmail()).isNull();
|
||||
assertThat(replacing.getNickname()).isNull();
|
||||
assertThat(replacing.getRoles()).isNull();
|
||||
assertThat(replacing.getAddress()).isNull();
|
||||
|
||||
var strict = new OrderMapperImpl();
|
||||
var lenient = new LenientOrderMapperImpl(strict);
|
||||
Order noLines = new Order(7L, null, null, null);
|
||||
t.section("whole objects and collections: OrderMapper (default) vs LenientOrderMapper (RETURN_DEFAULT)");
|
||||
t.line("strict.toDto(null) : %s", strict.toDto((Order) null));
|
||||
t.line("lenient.toDto(null) : %s", lenient.toDto(null));
|
||||
OrderDto strictNoLines = strict.toDto(noLines);
|
||||
OrderDto lenientNoLines = lenient.toDto(noLines);
|
||||
t.line("strict order, lines = null : lines=%s", strictNoLines.lines());
|
||||
t.line("lenient order, lines = null : lines=%s", lenientNoLines.lines());
|
||||
t.line("strict.toDtos(null) : %s", strict.toDtos(null));
|
||||
|
||||
assertThat(strict.toDto((Order) null)).isNull();
|
||||
assertThat(lenient.toDto(null)).isNotNull();
|
||||
assertThat(lenient.toDto(null).lines()).isNull();
|
||||
assertThat(strictNoLines.lines()).isNull();
|
||||
assertThat(lenientNoLines.lines()).isNotNull().isEmpty();
|
||||
assertThat(strict.toDtos(null)).isNull();
|
||||
|
||||
String generated = Fixtures.generatedMain("com.ankurm.mapstruct.mapper.CustomerMapperImpl");
|
||||
t.section("the generated patch() (IGNORE): every assignment is guarded");
|
||||
t.block(Fixtures.method(generated, "public void patch("));
|
||||
assertThat(Fixtures.method(generated, "public void patch(")).contains("if ( patch.email() != null )");
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void updatesMutateTheTargetInPlaceIncludingItsCollections() {
|
||||
try (var t = new Transcript("06-update-in-place.txt",
|
||||
"@MappingTarget updates: nested objects and collections are changed in place")) {
|
||||
Customer c = Fixtures.customer();
|
||||
Address addressBefore = c.getAddress();
|
||||
Set<String> rolesBefore = c.getRoles();
|
||||
mapper.patch(new CustomerPatch(null, null, new AddressDto(null, "Mumbai", null, null), Set.of("USER", "ADMIN")), c);
|
||||
|
||||
t.section("patch only the city and the roles");
|
||||
t.line("address: %s", describe(c));
|
||||
t.line("same Address instance as before? %s <- AddressMapper.update was called on it", c.getAddress() == addressBefore);
|
||||
t.line("same roles Set instance as before? %s <- cleared and refilled, not replaced", c.getRoles() == rolesBefore);
|
||||
|
||||
Customer shared = Fixtures.customer();
|
||||
Set<String> alias = shared.getRoles();
|
||||
mapper.patch(new CustomerPatch(null, null, null, Set.of("AUDITOR")), shared);
|
||||
t.line("another holder of the old roles Set now sees: %s", alias);
|
||||
|
||||
Customer frozen = Fixtures.customer();
|
||||
frozen.setRoles(Set.of("USER"));
|
||||
t.section("an immutable collection on the target");
|
||||
Throwable thrown = null;
|
||||
try {
|
||||
mapper.patch(new CustomerPatch(null, null, null, Set.of("ADMIN")), frozen);
|
||||
} catch (RuntimeException e) {
|
||||
thrown = e;
|
||||
}
|
||||
t.line("patching roles on a Set.of(...) target threw: %s", thrown == null ? "nothing" : thrown.getClass().getName());
|
||||
|
||||
assertThat(c.getAddress()).isSameAs(addressBefore);
|
||||
assertThat(c.getAddress().getStreet()).isEqualTo("12 Park Road");
|
||||
assertThat(c.getAddress().getCity()).isEqualTo("Mumbai");
|
||||
assertThat(c.getRoles()).isSameAs(rolesBefore).containsExactlyInAnyOrder("USER", "ADMIN");
|
||||
assertThat(alias).containsExactly("AUDITOR");
|
||||
assertThat(thrown).isInstanceOf(UnsupportedOperationException.class);
|
||||
assertThatThrownBy(() -> mapper.patch(new CustomerPatch(null, null, null, Set.of("X")), frozen))
|
||||
.isInstanceOf(UnsupportedOperationException.class);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package com.ankurm.mapstruct;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
import com.ankurm.mapstruct.mapper.CustomerMapper;
|
||||
import com.ankurm.mapstruct.web.CustomerController;
|
||||
import java.time.Clock;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.springframework.context.ConfigurableApplicationContext;
|
||||
|
||||
/** The whole application: every bean, every auto-configuration. Correct, and far more than a mapper test needs. */
|
||||
@SpringBootTest
|
||||
class SpringBootMapperTest {
|
||||
|
||||
@Autowired
|
||||
ConfigurableApplicationContext context;
|
||||
|
||||
@Autowired
|
||||
CustomerMapper mapper;
|
||||
|
||||
@Autowired
|
||||
Clock clock;
|
||||
|
||||
@Test
|
||||
void theWholeApplicationContext() {
|
||||
try (var t = new Transcript("13-testing-mappers-springboottest.txt",
|
||||
"@SpringBootTest: the same mapper, inside the whole application")) {
|
||||
int definitions = context.getBeanDefinitionCount();
|
||||
String name = mapper.toDto(Fixtures.customer(), Fixtures.clock()).fullName();
|
||||
t.line("bean definitions in the context : %d", definitions);
|
||||
t.line("includes the web controller : %s", context.getBeanNamesForType(CustomerController.class).length == 1);
|
||||
t.line("clock is the application's own : %s", clock.getZone());
|
||||
t.line("mapped : %s", name);
|
||||
|
||||
assertThat(definitions).isGreaterThan(100);
|
||||
assertThat(name).isEqualTo("Asha Mehta");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
package com.ankurm.mapstruct;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
import com.ankurm.mapstruct.domain.Customer;
|
||||
import com.ankurm.mapstruct.dto.AddressDto;
|
||||
import com.ankurm.mapstruct.dto.CustomerDto;
|
||||
import com.ankurm.mapstruct.mapper.AddressMapper;
|
||||
import com.ankurm.mapstruct.mapper.AddressMapperImpl;
|
||||
import com.ankurm.mapstruct.mapper.CustomerMapper;
|
||||
import com.ankurm.mapstruct.mapper.CustomerMapperImpl;
|
||||
import com.ankurm.mapstruct.mapper.OrderMapperImpl;
|
||||
import java.time.Clock;
|
||||
import java.time.LocalDate;
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.TreeSet;
|
||||
import java.util.function.Consumer;
|
||||
import java.util.function.Function;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mapstruct.factory.Mappers;
|
||||
|
||||
/** Four ways to test a generated mapper, and one way to make a new DTO component fail the build until it is tested. */
|
||||
class TestingStrategiesTests {
|
||||
|
||||
// ---- 1. plain unit tests ---------------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
void plainUnitTests() {
|
||||
try (var t = new Transcript("11-testing-mappers-plain.txt", "Testing a generated mapper without Spring: Mappers.getMapper versus constructing the Impl")) {
|
||||
AddressMapper viaFactory = Mappers.getMapper(AddressMapper.class);
|
||||
t.section("1. Mappers.getMapper(...)");
|
||||
t.line("AddressMapper (no dependencies) : %s", viaFactory.getClass().getSimpleName());
|
||||
|
||||
Throwable thrown = null;
|
||||
try {
|
||||
Mappers.getMapper(CustomerMapper.class);
|
||||
} catch (RuntimeException e) {
|
||||
thrown = e;
|
||||
}
|
||||
t.line("CustomerMapper (uses = {...}) : %s", thrown == null ? "worked" : thrown.getClass().getSimpleName());
|
||||
Throwable cause = thrown;
|
||||
while (cause != null && cause.getCause() != null) {
|
||||
cause = cause.getCause();
|
||||
}
|
||||
t.line(" root cause : %s", cause == null ? "-" : cause.getClass().getName() + ": " + cause.getMessage());
|
||||
|
||||
CustomerMapper byHand = new CustomerMapperImpl(new AddressMapperImpl(), new OrderMapperImpl());
|
||||
t.section("2. new CustomerMapperImpl(new AddressMapperImpl(), new OrderMapperImpl())");
|
||||
t.line("no Spring, no reflection : %s", byHand.toDto(Fixtures.customer(), Fixtures.clock()).fullName());
|
||||
|
||||
assertThat(viaFactory).isInstanceOf(AddressMapperImpl.class);
|
||||
assertThat(thrown).isNotNull();
|
||||
assertThat(cause).isInstanceOf(NoSuchMethodException.class);
|
||||
assertThat(byHand.toDto(Fixtures.customer(), Fixtures.clock()).fullName()).isEqualTo("Asha Mehta");
|
||||
}
|
||||
}
|
||||
|
||||
// ---- a property-level strategy ------------------------------------------------------------------
|
||||
|
||||
/** One mutation per DTO component: change the entity, and exactly that component must change. */
|
||||
private static Map<String, Mutation> mutations() {
|
||||
Map<String, Mutation> m = new LinkedHashMap<>();
|
||||
m.put("id", new Mutation(c -> c.setId(42L), CustomerDto::id));
|
||||
m.put("fullName", new Mutation(c -> c.setLastName("Shah"), CustomerDto::fullName));
|
||||
m.put("email", new Mutation(c -> c.setEmail("[email protected]"), CustomerDto::email));
|
||||
m.put("birthDate", new Mutation(c -> c.setBirthDate(LocalDate.of(2000, 1, 1)), CustomerDto::birthDate));
|
||||
m.put("age", new Mutation(c -> c.setBirthDate(LocalDate.of(2000, 1, 1)), CustomerDto::age));
|
||||
m.put("nickname", new Mutation(c -> c.setNickname("ash"), CustomerDto::nickname));
|
||||
m.put("address", new Mutation(c -> c.getAddress().setCity("Nagpur"), CustomerDto::address));
|
||||
m.put("roles", new Mutation(c -> c.getRoles().add("ADMIN"), CustomerDto::roles));
|
||||
m.put("attributes", new Mutation(c -> c.getAttributes().put("k", "v"), CustomerDto::attributes));
|
||||
m.put("orders", new Mutation(c -> c.getOrders().get(0).getLines().get(0).setQuantity(9), CustomerDto::orders));
|
||||
return m;
|
||||
}
|
||||
|
||||
private record Mutation(Consumer<Customer> change, Function<CustomerDto, Object> read) { }
|
||||
|
||||
@Test
|
||||
void everyDtoComponentIsCoveredByAMutationTest() {
|
||||
try (var t = new Transcript("14-property-level-test-strategy.txt",
|
||||
"Property-level mapper test: change one entity property, expect exactly the right DTO component to change")) {
|
||||
var mapper = Fixtures.customerMapper();
|
||||
Clock clock = Fixtures.clock();
|
||||
CustomerDto base = mapper.toDto(Fixtures.customer(), clock);
|
||||
List<String> failures = new ArrayList<>();
|
||||
|
||||
mutations().forEach((component, mutation) -> {
|
||||
Customer changed = Fixtures.customer();
|
||||
mutation.change().accept(changed);
|
||||
CustomerDto after = mapper.toDto(changed, clock);
|
||||
boolean moved = !mutation.read().apply(after).equals(mutation.read().apply(base));
|
||||
t.line("%-11s changes when its source changes : %s", component, moved);
|
||||
if (!moved) {
|
||||
failures.add(component);
|
||||
}
|
||||
});
|
||||
|
||||
var components = new TreeSet<String>();
|
||||
for (var rc : CustomerDto.class.getRecordComponents()) {
|
||||
components.add(rc.getName());
|
||||
}
|
||||
components.remove("origin"); // a constant: there is no source property to change
|
||||
t.blank();
|
||||
t.line("record components : %s", components);
|
||||
t.line("covered by a test : %s", new TreeSet<>(mutations().keySet()));
|
||||
|
||||
assertThat(failures).isEmpty();
|
||||
assertThat(new TreeSet<>(mutations().keySet())).isEqualTo(components);
|
||||
assertThat(base.origin()).isEqualTo("api");
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void mapperIsUsableThroughItsInterfaceOnly() {
|
||||
// The same assertion style a team would copy: build the expected record by hand.
|
||||
CustomerDto dto = Fixtures.customerMapper().toDto(Fixtures.customer(), Fixtures.clock());
|
||||
assertThat(dto.address()).isEqualTo(new AddressDto("12 Park Road", "Pune", "411001", "IN"));
|
||||
assertThatThrownBy(() -> Mappers.getMapper(CustomerMapper.class)).isInstanceOf(RuntimeException.class);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
package com.ankurm.mapstruct;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
|
||||
/**
|
||||
* Writes a numbered transcript under {@code output/} and echoes it to the console.
|
||||
* Every console block quoted in the article comes out of one of these files verbatim.
|
||||
* Lines end in LF on every platform so the files diff cleanly.
|
||||
*/
|
||||
public final class Transcript implements AutoCloseable {
|
||||
|
||||
private final Path path;
|
||||
private final StringBuilder buffer = new StringBuilder();
|
||||
|
||||
public Transcript(String fileName, String title) {
|
||||
this.path = Path.of("output", fileName);
|
||||
buffer.append("# ").append(title).append('\n').append('\n');
|
||||
}
|
||||
|
||||
public Transcript line(String format, Object... args) {
|
||||
buffer.append(args.length == 0 ? format : String.format(format, args)).append('\n');
|
||||
return this;
|
||||
}
|
||||
|
||||
public Transcript blank() {
|
||||
buffer.append('\n');
|
||||
return this;
|
||||
}
|
||||
|
||||
public Transcript section(String heading) {
|
||||
buffer.append('\n').append("--- ").append(heading).append(" ---").append('\n');
|
||||
return this;
|
||||
}
|
||||
|
||||
/** Appends a block of text verbatim (a trailing newline is added if missing). */
|
||||
public Transcript block(String text) {
|
||||
buffer.append(text);
|
||||
if (!text.endsWith("\n")) {
|
||||
buffer.append('\n');
|
||||
}
|
||||
return this;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() {
|
||||
// Absolute paths of whoever ran the build are environment noise, not a finding.
|
||||
String dir = System.getProperty("user.dir");
|
||||
String text = buffer.toString().replace(dir.replace('\\', '/'), "<mapstruct>").replace(dir, "<mapstruct>");
|
||||
try {
|
||||
Files.createDirectories(path.getParent());
|
||||
Files.writeString(path, text, StandardCharsets.UTF_8);
|
||||
} catch (IOException e) {
|
||||
throw new IllegalStateException("could not write " + path, e);
|
||||
}
|
||||
System.out.print(text);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
package com.ankurm.mapstruct;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
|
||||
|
||||
import com.ankurm.mapstruct.mapper.AddressMapperImpl;
|
||||
import com.ankurm.mapstruct.mapper.CustomerMapperImpl;
|
||||
import com.ankurm.mapstruct.mapper.OrderMapperImpl;
|
||||
import com.ankurm.mapstruct.web.CustomerController;
|
||||
import com.ankurm.mapstruct.web.CustomerRepository;
|
||||
import java.time.Clock;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.context.TestConfiguration;
|
||||
import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Import;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.test.web.servlet.MockMvc;
|
||||
import tools.jackson.databind.json.JsonMapper;
|
||||
|
||||
/** Records over HTTP on Boot 4: Jackson 3 reads and writes them with no extra configuration. */
|
||||
@WebMvcTest(CustomerController.class)
|
||||
@Import({CustomerMapperImpl.class, AddressMapperImpl.class, OrderMapperImpl.class, CustomerRepository.class,
|
||||
WebJsonTests.PinnedClock.class})
|
||||
class WebJsonTests {
|
||||
|
||||
@TestConfiguration
|
||||
static class PinnedClock {
|
||||
@Bean
|
||||
Clock clock() {
|
||||
return Fixtures.clock();
|
||||
}
|
||||
}
|
||||
|
||||
@Autowired
|
||||
MockMvc mvc;
|
||||
|
||||
@Autowired
|
||||
JsonMapper jsonMapper;
|
||||
|
||||
@Test
|
||||
void recordsRoundTripThroughJackson3AndTheMapper() throws Exception {
|
||||
try (var t = new Transcript("15-spring-boot-4-records-over-http.txt",
|
||||
"MockMvc against CustomerController: records in, records out, Jackson 3 in the middle")) {
|
||||
t.line("JSON library bean : %s", jsonMapper.getClass().getName());
|
||||
|
||||
String get = mvc.perform(get("/customers/1")).andReturn().getResponse().getContentAsString();
|
||||
t.section("GET /customers/1");
|
||||
t.line(get);
|
||||
|
||||
String created = mvc.perform(post("/customers").contentType(MediaType.APPLICATION_JSON).content("""
|
||||
{"firstName":"Ravi","lastName":"Iyer","email":" [email protected] ","birthDate":"1985-01-02",
|
||||
"address":{"street":"1 Lake View","city":"Chennai","zip":"600001"},"roles":["ADMIN"]}"""))
|
||||
.andReturn().getResponse().getContentAsString();
|
||||
t.section("POST /customers (no nickname, no tier, no country)");
|
||||
t.line(created);
|
||||
|
||||
String patched = mvc.perform(patch("/customers/1").contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"nickname\":\"ash\"}")).andReturn().getResponse().getContentAsString();
|
||||
t.section("PATCH /customers/1 {\"nickname\":\"ash\"} (every other component of the patch record is null)");
|
||||
t.line(patched);
|
||||
|
||||
int missing = mvc.perform(get("/customers/99")).andReturn().getResponse().getStatus();
|
||||
t.section("GET /customers/99");
|
||||
t.line("status %d", missing);
|
||||
|
||||
assertThat(jsonMapper.getClass().getName()).startsWith("tools.jackson.");
|
||||
assertThat(get).contains("\"fullName\":\"Asha Mehta\"").contains("\"age\":36")
|
||||
.contains("\"birthDate\":\"1990-06-15\"").contains("\"nickname\":\"anonymous\"")
|
||||
.contains("\"zip\":\"411001\"").doesNotContain("passwordHash");
|
||||
assertThat(created).contains("\"email\":\"[email protected]\"").contains("\"fullName\":\"Ravi Iyer\"");
|
||||
assertThat(patched).contains("\"nickname\":\"ash\"").contains("\"email\":\"[email protected]\"");
|
||||
assertThat(missing).isEqualTo(404);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package com.ankurm.mapstruct.probe;
|
||||
|
||||
import java.util.IdentityHashMap;
|
||||
import java.util.Map;
|
||||
import org.mapstruct.BeforeMapping;
|
||||
import org.mapstruct.MappingTarget;
|
||||
import org.mapstruct.TargetType;
|
||||
|
||||
/** The standard recipe: remember every source object already mapped and reuse its target. */
|
||||
public class CycleContext {
|
||||
|
||||
private final Map<Object, Object> known = new IdentityHashMap<>();
|
||||
|
||||
@BeforeMapping
|
||||
public <T> T lookup(Object source, @TargetType Class<T> targetType) {
|
||||
return targetType.cast(known.get(source));
|
||||
}
|
||||
|
||||
@BeforeMapping
|
||||
public void remember(Object source, @MappingTarget Object target) {
|
||||
known.put(source, target);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package com.ankurm.mapstruct.probe;
|
||||
|
||||
import com.ankurm.mapstruct.probe.Probes.Employee;
|
||||
import com.ankurm.mapstruct.probe.Probes.EmployeeBean;
|
||||
import com.ankurm.mapstruct.probe.Probes.EmployeeRecord;
|
||||
import org.mapstruct.Context;
|
||||
import org.mapstruct.Mapper;
|
||||
|
||||
@Mapper
|
||||
public interface CycleMapper {
|
||||
|
||||
EmployeeRecord toRecord(Employee employee, @Context CycleContext context);
|
||||
|
||||
EmployeeBean toBean(Employee employee, @Context CycleContext context);
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package com.ankurm.mapstruct.probe;
|
||||
|
||||
import com.ankurm.mapstruct.probe.Probes.Boxed;
|
||||
import com.ankurm.mapstruct.probe.Probes.Pair;
|
||||
import org.mapstruct.Mapper;
|
||||
import org.mapstruct.ObjectFactory;
|
||||
import org.mapstruct.ReportingPolicy;
|
||||
|
||||
@Mapper(unmappedTargetPolicy = ReportingPolicy.ERROR)
|
||||
public interface FactoryTrapMapper {
|
||||
|
||||
Boxed toRecord(Pair pair);
|
||||
|
||||
@ObjectFactory
|
||||
default Boxed create(Pair pair) {
|
||||
return new Boxed("from-the-factory", 0);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
package com.ankurm.mapstruct.probe;
|
||||
|
||||
import com.ankurm.mapstruct.domain.Customer;
|
||||
import com.ankurm.mapstruct.domain.Order;
|
||||
import com.ankurm.mapstruct.domain.OrderLine;
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
|
||||
/** The mapper you would write by hand: what MapStruct's output is being compared with. */
|
||||
public final class HandWritten {
|
||||
|
||||
private HandWritten() { }
|
||||
|
||||
public static Views.CustomerView toView(Customer c) {
|
||||
if (c == null) {
|
||||
return null;
|
||||
}
|
||||
Views.CustomerView v = new Views.CustomerView();
|
||||
v.setId(c.getId());
|
||||
v.setFirstName(c.getFirstName());
|
||||
v.setLastName(c.getLastName());
|
||||
v.setEmail(c.getEmail());
|
||||
v.setNickname(c.getNickname());
|
||||
v.setTier(c.getTier());
|
||||
if (c.getAddress() != null) {
|
||||
Views.AddressView a = new Views.AddressView();
|
||||
a.setStreet(c.getAddress().getStreet());
|
||||
a.setCity(c.getAddress().getCity());
|
||||
a.setPostalCode(c.getAddress().getPostalCode());
|
||||
a.setCountry(c.getAddress().getCountry());
|
||||
v.setAddress(a);
|
||||
}
|
||||
if (c.getRoles() != null) {
|
||||
v.setRoles(new LinkedHashSet<>(c.getRoles()));
|
||||
}
|
||||
if (c.getAttributes() != null) {
|
||||
v.setAttributes(new LinkedHashMap<>(c.getAttributes()));
|
||||
}
|
||||
if (c.getOrders() != null) {
|
||||
List<Views.OrderView> orders = new ArrayList<>(c.getOrders().size());
|
||||
for (Order o : c.getOrders()) {
|
||||
Views.OrderView ov = new Views.OrderView();
|
||||
ov.setId(o.getId());
|
||||
ov.setStatus(o.getStatus() == null ? null : o.getStatus().name());
|
||||
if (o.getLines() != null) {
|
||||
List<Views.LineView> lines = new ArrayList<>(o.getLines().size());
|
||||
for (OrderLine l : o.getLines()) {
|
||||
Views.LineView lv = new Views.LineView();
|
||||
lv.setSku(l.getSku());
|
||||
lv.setQuantity(l.getQuantity());
|
||||
lv.setUnitPrice(l.getUnitPrice());
|
||||
lines.add(lv);
|
||||
}
|
||||
ov.setLines(lines);
|
||||
}
|
||||
orders.add(ov);
|
||||
}
|
||||
v.setOrders(orders);
|
||||
}
|
||||
return v;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package com.ankurm.mapstruct.probe;
|
||||
|
||||
import static com.ankurm.mapstruct.probe.Probes.TRACE;
|
||||
|
||||
import com.ankurm.mapstruct.probe.Probes.Boxed;
|
||||
import com.ankurm.mapstruct.probe.Probes.BoxedBean;
|
||||
import com.ankurm.mapstruct.probe.Probes.Pair;
|
||||
import org.mapstruct.AfterMapping;
|
||||
import org.mapstruct.BeforeMapping;
|
||||
import org.mapstruct.Mapper;
|
||||
import org.mapstruct.MappingTarget;
|
||||
|
||||
@Mapper
|
||||
public interface HookOrderMapper {
|
||||
|
||||
Boxed toRecord(Pair pair);
|
||||
|
||||
BoxedBean toBean(Pair pair);
|
||||
|
||||
@BeforeMapping
|
||||
default void beforeSource(Pair pair) {
|
||||
TRACE.add("@BeforeMapping(source)");
|
||||
}
|
||||
|
||||
@BeforeMapping
|
||||
default void beforeRecord(Pair pair, @MappingTarget Boxed target) {
|
||||
TRACE.add("@BeforeMapping(source, @MappingTarget Boxed) target=" + target);
|
||||
}
|
||||
|
||||
@AfterMapping
|
||||
default void afterRecord(Pair pair, @MappingTarget Boxed target) {
|
||||
TRACE.add("@AfterMapping(source, @MappingTarget Boxed) target=" + target);
|
||||
}
|
||||
|
||||
@BeforeMapping
|
||||
default void beforeBean(Pair pair, @MappingTarget BoxedBean target) {
|
||||
TRACE.add("@BeforeMapping(source, @MappingTarget BoxedBean) name=" + target.getName());
|
||||
}
|
||||
|
||||
@AfterMapping
|
||||
default void afterBean(Pair pair, @MappingTarget BoxedBean target) {
|
||||
TRACE.add("@AfterMapping(source, @MappingTarget BoxedBean) name=" + target.getName());
|
||||
}
|
||||
|
||||
@AfterMapping
|
||||
default void afterSource(Pair pair) {
|
||||
TRACE.add("@AfterMapping(source)");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package com.ankurm.mapstruct.probe;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Small mappers in this package exist only to answer "what does MapStruct do when...?" questions.
|
||||
* They use MapStruct's default component model (plain classes, no Spring) and live in test sources.
|
||||
*/
|
||||
public final class Probes {
|
||||
|
||||
private Probes() { }
|
||||
|
||||
/** Everything the hooks do is appended here, in call order. */
|
||||
public static final List<String> TRACE = new ArrayList<>();
|
||||
|
||||
public record Pair(String name, int qty) { }
|
||||
|
||||
public record Boxed(String name, int qty) { }
|
||||
|
||||
public static class BoxedBean {
|
||||
private String name;
|
||||
private int qty;
|
||||
public String getName() { return name; }
|
||||
public void setName(String name) { TRACE.add(" BoxedBean.setName(" + name + ")"); this.name = name; }
|
||||
public int getQty() { return qty; }
|
||||
public void setQty(int qty) { TRACE.add(" BoxedBean.setQty(" + qty + ")"); this.qty = qty; }
|
||||
}
|
||||
|
||||
public static class Employee {
|
||||
public String name;
|
||||
public Employee manager;
|
||||
public Employee(String name) { this.name = name; }
|
||||
}
|
||||
|
||||
public record EmployeeRecord(String name, EmployeeRecord manager) { }
|
||||
|
||||
public static class EmployeeBean {
|
||||
private String name;
|
||||
private EmployeeBean manager;
|
||||
public String getName() { return name; }
|
||||
public void setName(String name) { this.name = name; }
|
||||
public EmployeeBean getManager() { return manager; }
|
||||
public void setManager(EmployeeBean manager) { this.manager = manager; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
package com.ankurm.mapstruct.probe;
|
||||
|
||||
import com.ankurm.mapstruct.domain.Customer;
|
||||
import org.mapstruct.Mapper;
|
||||
|
||||
/** Same-named properties, so MapStruct needs no @Mapping at all. */
|
||||
@Mapper
|
||||
public interface ViewMapper {
|
||||
Views.CustomerView toView(Customer customer);
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
package com.ankurm.mapstruct.probe;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
|
||||
/** Mutable, setter-based copies of the DTO shape. The comparison uses setter-based beans because Dozer is a bean mapper. */
|
||||
public final class Views {
|
||||
|
||||
private Views() { }
|
||||
|
||||
public static class AddressView {
|
||||
private String street; private String city; private String postalCode; private String country;
|
||||
public String getStreet() { return street; }
|
||||
public void setStreet(String v) { street = v; }
|
||||
public String getCity() { return city; }
|
||||
public void setCity(String v) { city = v; }
|
||||
public String getPostalCode() { return postalCode; }
|
||||
public void setPostalCode(String v) { postalCode = v; }
|
||||
public String getCountry() { return country; }
|
||||
public void setCountry(String v) { country = v; }
|
||||
}
|
||||
|
||||
public static class LineView {
|
||||
private String sku; private int quantity; private BigDecimal unitPrice;
|
||||
public String getSku() { return sku; }
|
||||
public void setSku(String v) { sku = v; }
|
||||
public int getQuantity() { return quantity; }
|
||||
public void setQuantity(int v) { quantity = v; }
|
||||
public BigDecimal getUnitPrice() { return unitPrice; }
|
||||
public void setUnitPrice(BigDecimal v) { unitPrice = v; }
|
||||
}
|
||||
|
||||
public static class OrderView {
|
||||
private Long id; private String status; private List<LineView> lines;
|
||||
public Long getId() { return id; }
|
||||
public void setId(Long v) { id = v; }
|
||||
public String getStatus() { return status; }
|
||||
public void setStatus(String v) { status = v; }
|
||||
public List<LineView> getLines() { return lines; }
|
||||
public void setLines(List<LineView> v) { lines = v; }
|
||||
}
|
||||
|
||||
public static class CustomerView {
|
||||
private Long id; private String firstName; private String lastName; private String email;
|
||||
private String nickname; private String tier; private AddressView address;
|
||||
private Set<String> roles; private Map<String, String> attributes; private List<OrderView> orders;
|
||||
public Long getId() { return id; }
|
||||
public void setId(Long v) { id = v; }
|
||||
public String getFirstName() { return firstName; }
|
||||
public void setFirstName(String v) { firstName = v; }
|
||||
public String getLastName() { return lastName; }
|
||||
public void setLastName(String v) { lastName = v; }
|
||||
public String getEmail() { return email; }
|
||||
public void setEmail(String v) { email = v; }
|
||||
public String getNickname() { return nickname; }
|
||||
public void setNickname(String v) { nickname = v; }
|
||||
public String getTier() { return tier; }
|
||||
public void setTier(String v) { tier = v; }
|
||||
public AddressView getAddress() { return address; }
|
||||
public void setAddress(AddressView v) { address = v; }
|
||||
public Set<String> getRoles() { return roles; }
|
||||
public void setRoles(Set<String> v) { roles = v; }
|
||||
public Map<String, String> getAttributes() { return attributes; }
|
||||
public void setAttributes(Map<String, String> v) { attributes = v; }
|
||||
public List<OrderView> getOrders() { return orders; }
|
||||
public void setOrders(List<OrderView> v) { orders = v; }
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user