New posts for Spring and Hibernate
This commit is contained in:
@@ -54,6 +54,8 @@ files.
|
|||||||
| [`boot42-rc1-checklist/`](boot42-rc1-checklist) | [Spring Framework 7.1 and Spring Boot 4.2: What's Coming and How to Prepare](https://ankurm.com/spring-boot-4-2-spring-framework-7-1-upgrade-checklist/) | each 4.2 milestone change run on 4.2.0-RC1 and on 4.1.2: `@DefaultValue` placeholders resolved, five new Kafka admin keys that 4.1.2 silently ignores, embedded LDAP SSL failing without a bundle, the properties migrator's real report (and the removed key it misses), and the `RestTemplate` removal warnings from the compiler |
|
| [`boot42-rc1-checklist/`](boot42-rc1-checklist) | [Spring Framework 7.1 and Spring Boot 4.2: What's Coming and How to Prepare](https://ankurm.com/spring-boot-4-2-spring-framework-7-1-upgrade-checklist/) | each 4.2 milestone change run on 4.2.0-RC1 and on 4.1.2: `@DefaultValue` placeholders resolved, five new Kafka admin keys that 4.1.2 silently ignores, embedded LDAP SSL failing without a bundle, the properties migrator's real report (and the removed key it misses), and the `RestTemplate` removal warnings from the compiler |
|
||||||
| [`boot42-otel/`](boot42-otel) | [Spring Boot 4.2 OpenTelemetry: Semantic Conventions, Shared OTLP Settings and MDC Keys](https://ankurm.com/spring-boot-4-2-opentelemetry-semantic-conventions-shared-otlp-mdc/) | one request measured under each `management.observations.conventions` value (the documented `open-telemetry` fails to start on RC1, `opentelemetry` works), meter, span and Prometheus names before and after, `management.opentelemetry.otlp.*` verified against a local OTLP receiver (paths, merged headers, gzip, per-signal overrides), custom MDC keys, and the LongTaskTimer that is no longer on by default |
|
| [`boot42-otel/`](boot42-otel) | [Spring Boot 4.2 OpenTelemetry: Semantic Conventions, Shared OTLP Settings and MDC Keys](https://ankurm.com/spring-boot-4-2-opentelemetry-semantic-conventions-shared-otlp-mdc/) | one request measured under each `management.observations.conventions` value (the documented `open-telemetry` fails to start on RC1, `opentelemetry` works), meter, span and Prometheus names before and after, `management.opentelemetry.otlp.*` verified against a local OTLP receiver (paths, merged headers, gzip, per-signal overrides), custom MDC keys, and the LongTaskTimer that is no longer on by default |
|
||||||
| [`boot4-migration/`](boot4-migration) | [Spring Boot 3 to 4 Migration Guide (Updated for 4.1 and 4.2)](https://ankurm.com/spring-boot-3-to-4-migration-guide/) | one Boot 3.5.16 service migrated to 4.1.2 with the real failures captured at each step: 12 javac errors from a version bump, a start-up failure from a missing `spring-boot-starter-restclient`, Flyway silently not running without `spring-boot-starter-flyway`, removed `logging.file.*` keys silently ignored, Jackson 3 sorting bean properties, liveness/readiness groups appearing, and a `@Mock` field that is `null` without `MockitoExtension` |
|
| [`boot4-migration/`](boot4-migration) | [Spring Boot 3 to 4 Migration Guide (Updated for 4.1 and 4.2)](https://ankurm.com/spring-boot-3-to-4-migration-guide/) | one Boot 3.5.16 service migrated to 4.1.2 with the real failures captured at each step: 12 javac errors from a version bump, a start-up failure from a missing `spring-boot-starter-restclient`, Flyway silently not running without `spring-boot-starter-flyway`, removed `logging.file.*` keys silently ignored, Jackson 3 sorting bean properties, liveness/readiness groups appearing, and a `@Mock` field that is `null` without `MockitoExtension` |
|
||||||
|
| [`startup-runners/`](startup-runners) | [CommandLineRunner vs ApplicationRunner vs ApplicationReadyEvent in Spring Boot 4](https://ankurm.com/commandlinerunner-vs-applicationrunner-vs-applicationreadyevent-spring-boot-4/) | the real order of every startup hook printed from a running app, one shared sort for both runner types with arbitrary ties, runner failure vs `ApplicationReadyEvent` listener failure (exit codes, `ApplicationFailedEvent`), a blocking runner holding readiness at 503, `SmartLifecycle` phases, and which hooks `@SpringBootTest` and `@WebMvcTest` run |
|
||||||
|
| [`mapstruct/`](mapstruct) | [MapStruct with Spring Boot 4 and Java Records: DTO Mapping Done Right](https://ankurm.com/mapstruct-spring-boot-4-java-records-dto-mapping/) | records as source and target, nested and collection mapping, null strategies, `@AfterMapping` and `@DecoratedWith`, real compile-time error text for broken mappers, three ways to test a mapper, and an indicative comparison with hand-written code and Dozer |
|
||||||
|
|
||||||
`core-di`, `core-beans`, `custom-starter`, `core-events`, `multi-datasource`, `i18n`, `aot-cache`, `kotlin`, `testing-no-containers`, `rest-test-client` and `hexagonal` are the exception: they have **no `docs/NN-topic.md` chapter folder**. Their deeper material lives in
|
`core-di`, `core-beans`, `custom-starter`, `core-events`, `multi-datasource`, `i18n`, `aot-cache`, `kotlin`, `testing-no-containers`, `rest-test-client` and `hexagonal` are the exception: they have **no `docs/NN-topic.md` chapter folder**. Their deeper material lives in
|
||||||
collapsible sections inside the articles themselves. Where their captured output sits is not
|
collapsible sections inside the articles themselves. Where their captured output sits is not
|
||||||
|
|||||||
@@ -0,0 +1,78 @@
|
|||||||
|
# mapstruct
|
||||||
|
|
||||||
|
Companion project for the article **[MapStruct with Spring Boot 4 and Java Records: DTO Mapping Done Right](https://ankurm.com/mapstruct-spring-boot-4-java-records-dto-mapping/)** on **[ankurm.com](https://ankurm.com)**.
|
||||||
|
|
||||||
|
Every console block quoted in the article came out of `output/`. Transcripts 01-16 are written by the test suite
|
||||||
|
(so a claim that stops being true turns the build red); 17-24 are the compiler's own words, captured by
|
||||||
|
`scripts/compile-errors.sh` from the deliberately broken sources in `src/broken/`; 25 is read out of the
|
||||||
|
processor jar by `scripts/capture-facts.sh`.
|
||||||
|
|
||||||
|
There is deliberately **no `docs/` folder**: the deeper material lives in collapsible "going deeper"
|
||||||
|
sections inside the article itself.
|
||||||
|
|
||||||
|
## Versions
|
||||||
|
|
||||||
|
| | |
|
||||||
|
|---|---|
|
||||||
|
| Spring Boot | 4.1.2 (newest GA in Maven Central's metadata when this was written; the sibling modules use 4.1.1) |
|
||||||
|
| Spring Framework | 7.0.10 |
|
||||||
|
| MapStruct | 1.6.3 (newest GA; 1.7.0.Beta2 exists and is not used) |
|
||||||
|
| Jackson | 3.1.7 (`tools.jackson`) |
|
||||||
|
| Dozer (test scope, timing only) | 7.0.0 |
|
||||||
|
| JDK | 25 |
|
||||||
|
| Maven | 3.9 |
|
||||||
|
|
||||||
|
No Lombok. Records make it unnecessary here, and `scripts/compile-errors.sh` shows what Lombok does to the
|
||||||
|
processor path when you do use it (transcript 24).
|
||||||
|
|
||||||
|
## Quickstart
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export JAVA_HOME=/path/to/jdk-25
|
||||||
|
mvn test # runs the scenarios and rewrites output/01-16
|
||||||
|
./scripts/run-all.sh # everything, including 17-25 (needs the jars from one `mvn test` in ~/.m2)
|
||||||
|
```
|
||||||
|
|
||||||
|
The compiler plugin gets the generator through `annotationProcessorPaths` (see `pom.xml`). The generated
|
||||||
|
sources land in `target/generated-sources/annotations/`, where the tests read them back to print them.
|
||||||
|
|
||||||
|
## Source layout
|
||||||
|
|
||||||
|
| Path | What it holds |
|
||||||
|
|---|---|
|
||||||
|
| `domain/` | mutable entity-style classes: `Customer`, `Address`, `Order`, `OrderLine` |
|
||||||
|
| `dto/` | records: `CustomerDto`, `CustomerRequest`, `CustomerPatch`, `AddressDto`, `OrderDto`, `ReceiptDto` (with a builder), ... |
|
||||||
|
| `mapper/` | `CentralConfig` (Spring beans, constructor injection, unmapped target = error) and the mappers |
|
||||||
|
| `web/` | a small controller and an in-memory repository, so the records can be seen over HTTP |
|
||||||
|
| `src/test/.../probe/` | throwaway mappers that answer "what happens if...": hook order, `@ObjectFactory`, cycles, timing |
|
||||||
|
| `src/broken/` | sources that must not compile; never part of the Maven build |
|
||||||
|
|
||||||
|
## Captured output
|
||||||
|
|
||||||
|
| File | What it shows |
|
||||||
|
|---|---|
|
||||||
|
| `01-records-as-source-and-target.txt` | a record target has no setters; the generated code calls the constructor |
|
||||||
|
| `02-generated-implementation.txt` | the generated `AddressMapperImpl` and `CustomerMapperImpl.toDto`, read from `target/` |
|
||||||
|
| `03-entity-to-record.txt` | nested objects, List/Set/Map, expression, constant, defaultValue, qualifiedByName, `@Context` |
|
||||||
|
| `04-record-to-entity.txt` | a record as source, `@AfterMapping`, `@InheritInverseConfiguration`, `nullValueCheckStrategy` |
|
||||||
|
| `05-null-strategies.txt` | `IGNORE` versus the default on a patch; `RETURN_DEFAULT` for whole objects and collections |
|
||||||
|
| `06-update-in-place.txt` | `@MappingTarget` updates mutate nested objects and collections, including immutable ones |
|
||||||
|
| `07-hooks-on-records.txt` | `@BeforeMapping` / `@AfterMapping` call order for a record target and a bean target |
|
||||||
|
| `08-object-factory-trap.txt` | an `@ObjectFactory` that returns a record: nothing is copied, no error |
|
||||||
|
| `09-builder-and-decorator.txt` | post-processing a record through its builder, and through `@DecoratedWith` |
|
||||||
|
| `10-context-and-cycles.txt` | `@Context` cycle tracking works for beans and cannot work for records |
|
||||||
|
| `11-testing-mappers-plain.txt` | `Mappers.getMapper` versus constructing the generated class |
|
||||||
|
| `12-testing-mappers-contextconfiguration.txt` | a Spring context of just the generated classes |
|
||||||
|
| `13-testing-mappers-springboottest.txt` | the same mapper inside `@SpringBootTest` |
|
||||||
|
| `14-property-level-test-strategy.txt` | one mutation per record component, and a completeness check |
|
||||||
|
| `15-spring-boot-4-records-over-http.txt` | MockMvc, Jackson 3, records in and out |
|
||||||
|
| `16-indicative-timing-vs-handwritten-and-dozer.txt` | **indicative** timings; differs on every run |
|
||||||
|
| `17-error-unmapped-target.txt` | javac: `Unmapped target property` |
|
||||||
|
| `18-error-ambiguous-mapping-methods.txt` | javac: `Ambiguous mapping methods found` |
|
||||||
|
| `19-error-missing-source-property.txt` | javac: `No property named ... Did you mean ...?` |
|
||||||
|
| `20-update-method-on-a-record-compiles-to-a-no-op.txt` | `@MappingTarget` on a record compiles, and generates an empty method |
|
||||||
|
| `21-error-cannot-map-type.txt` | javac: `Can't map property` |
|
||||||
|
| `22-error-decorator-constructor.txt` | javac: the generated decorator subclass has no constructor to call |
|
||||||
|
| `23-processor-on-the-plain-classpath.txt` | JDK 25 compiles without running the processor if it is only on `-cp` |
|
||||||
|
| `24-lombok-processor-order.txt` | Lombok before/after MapStruct, with and without `lombok-mapstruct-binding` |
|
||||||
|
| `25-processor-jar-facts.txt` | the processor service entry and its `-A` options, read with `javap` |
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
# A record target has no setters: MapStruct calls the canonical constructor
|
||||||
|
|
||||||
|
CustomerDto is a record : true
|
||||||
|
setters on CustomerDto : 0
|
||||||
|
setters on Customer (entity) : 14
|
||||||
|
|
||||||
|
--- the line the generated toDto uses to build the record ---
|
||||||
|
CustomerDto customerDto = new CustomerDto( id, fullName, email, birthDate, age, nickname, address, roles, attributes, orders, origin );
|
||||||
|
|
||||||
|
--- record as SOURCE: the generated toEntity reads accessors, not getters ---
|
||||||
|
customer.setFirstName( request.firstName() );
|
||||||
|
customer.setLastName( request.lastName() );
|
||||||
|
customer.setEmail( request.email() );
|
||||||
|
customer.setBirthDate( request.birthDate() );
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
# What MapStruct 1.6.3 generated, read from target/generated-sources (header and imports trimmed)
|
||||||
|
|
||||||
|
|
||||||
|
--- AddressMapperImpl ---
|
||||||
|
@Component
|
||||||
|
public class AddressMapperImpl implements AddressMapper {
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public AddressDto toDto(Address address) {
|
||||||
|
if ( address == null ) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
String zip = null;
|
||||||
|
String country = null;
|
||||||
|
String street = null;
|
||||||
|
String city = null;
|
||||||
|
|
||||||
|
zip = address.getPostalCode();
|
||||||
|
if ( address.getCountry() != null ) {
|
||||||
|
country = address.getCountry();
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
country = "IN";
|
||||||
|
}
|
||||||
|
street = address.getStreet();
|
||||||
|
city = address.getCity();
|
||||||
|
|
||||||
|
AddressDto addressDto = new AddressDto( street, city, zip, country );
|
||||||
|
|
||||||
|
return addressDto;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Address toEntity(AddressDto dto) {
|
||||||
|
if ( dto == null ) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
Address address = new Address();
|
||||||
|
|
||||||
|
address.setPostalCode( dto.zip() );
|
||||||
|
address.setCountry( dto.country() );
|
||||||
|
address.setStreet( dto.street() );
|
||||||
|
address.setCity( dto.city() );
|
||||||
|
|
||||||
|
return address;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void update(AddressDto dto, Address address) {
|
||||||
|
if ( dto == null ) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ( dto.zip() != null ) {
|
||||||
|
address.setPostalCode( dto.zip() );
|
||||||
|
}
|
||||||
|
if ( dto.street() != null ) {
|
||||||
|
address.setStreet( dto.street() );
|
||||||
|
}
|
||||||
|
if ( dto.city() != null ) {
|
||||||
|
address.setCity( dto.city() );
|
||||||
|
}
|
||||||
|
if ( dto.country() != null ) {
|
||||||
|
address.setCountry( dto.country() );
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
--- CustomerMapperImpl: constructor injection, then toDto ---
|
||||||
|
@Component
|
||||||
|
public class CustomerMapperImpl implements CustomerMapper {
|
||||||
|
|
||||||
|
private final AddressMapper addressMapper;
|
||||||
|
private final OrderMapper orderMapper;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
public CustomerMapperImpl(AddressMapper addressMapper, OrderMapper orderMapper) {
|
||||||
|
|
||||||
|
this.addressMapper = addressMapper;
|
||||||
|
this.orderMapper = orderMapper;
|
||||||
|
}
|
||||||
|
|
||||||
|
public CustomerDto toDto(Customer customer, Clock clock) {
|
||||||
|
if ( customer == null ) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
int age = 0;
|
||||||
|
String nickname = null;
|
||||||
|
Long id = null;
|
||||||
|
String email = null;
|
||||||
|
LocalDate birthDate = null;
|
||||||
|
AddressDto address = null;
|
||||||
|
Set<String> roles = null;
|
||||||
|
Map<String, String> attributes = null;
|
||||||
|
List<OrderDto> orders = null;
|
||||||
|
|
||||||
|
age = ageInYears( customer.getBirthDate(), clock );
|
||||||
|
if ( customer.getNickname() != null ) {
|
||||||
|
nickname = customer.getNickname();
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
nickname = "anonymous";
|
||||||
|
}
|
||||||
|
id = customer.getId();
|
||||||
|
email = customer.getEmail();
|
||||||
|
birthDate = customer.getBirthDate();
|
||||||
|
address = addressMapper.toDto( customer.getAddress() );
|
||||||
|
Set<String> set = customer.getRoles();
|
||||||
|
if ( set != null ) {
|
||||||
|
roles = new LinkedHashSet<String>( set );
|
||||||
|
}
|
||||||
|
Map<String, String> map = customer.getAttributes();
|
||||||
|
if ( map != null ) {
|
||||||
|
attributes = new LinkedHashMap<String, String>( map );
|
||||||
|
}
|
||||||
|
orders = orderMapper.toDtos( customer.getOrders() );
|
||||||
|
|
||||||
|
String fullName = customer.getFirstName() + " " + customer.getLastName();
|
||||||
|
String origin = "api";
|
||||||
|
|
||||||
|
CustomerDto customerDto = new CustomerDto( id, fullName, email, birthDate, age, nickname, address, roles, attributes, orders, origin );
|
||||||
|
|
||||||
|
return customerDto;
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
# Customer (mutable entity) -> CustomerDto (record), with nested Address, a List of Orders, a Set and a Map
|
||||||
|
|
||||||
|
fullName : Asha Mehta <- expression = java(first + " " + last)
|
||||||
|
age : 36 <- qualifiedByName, with the clock passed as @Context
|
||||||
|
nickname : anonymous <- source was null, defaultValue = "anonymous"
|
||||||
|
origin : api <- constant = "api"
|
||||||
|
address : AddressDto[street=12 Park Road, city=Pune, zip=411001, country=IN] <- postalCode renamed to zip; null country got defaultValue IN
|
||||||
|
roles : [USER] same instance as the entity's? false
|
||||||
|
attributes : {newsletter=yes} same instance as the entity's? false
|
||||||
|
|
||||||
|
--- List<Order> -> List<OrderDto>, with a computed total ---
|
||||||
|
order id=100 status=PAID total=32.50
|
||||||
|
OrderLineDto[sku=BOOK-1, quantity=2, unitPrice=12.50, lineTotal=25.00]
|
||||||
|
OrderLineDto[sku=PEN-9, quantity=10, unitPrice=0.75, lineTotal=7.50]
|
||||||
|
|
||||||
|
--- what is NOT in the record ---
|
||||||
|
passwordHash is a component of CustomerDto? false
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
# CustomerRequest (record) -> Customer (entity); AddressMapper.toEntity inherits the rename in reverse
|
||||||
|
|
||||||
|
email after @AfterMapping : '[email protected]' (request carried ' [email protected] ')
|
||||||
|
createdAt (from @Context) : 2026-11-05T00:00:00Z
|
||||||
|
address.postalCode : 600001 <- @InheritInverseConfiguration turned zip back into postalCode
|
||||||
|
address.country : null <- defaultValue is NOT inverted; toDto gives IN, toEntity does not
|
||||||
|
tier : null <- the entity said STANDARD; the request said null
|
||||||
|
tier with nullValueCheckStrategy = ALWAYS : STANDARD
|
||||||
|
id / passwordHash / orders : null / null / null <- ignore = true
|
||||||
|
|
||||||
|
--- round trip: entity -> record -> entity ---
|
||||||
|
passwordHash survives the round trip? false
|
||||||
|
address country survives? null -> IN
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
# Null handling: IGNORE vs the default on a patch, and RETURN_DEFAULT on whole objects and collections
|
||||||
|
|
||||||
|
|
||||||
|
--- patch(): nullValuePropertyMappingStrategy = IGNORE, applied with a patch whose components are all null ---
|
||||||
|
before: [email protected], nickname=ash, roles=[USER], address=12 Park Road/Pune/411001
|
||||||
|
after : [email protected], nickname=ash, roles=[USER], address=12 Park Road/Pune/411001
|
||||||
|
|
||||||
|
--- replace(): the default strategy (SET_TO_NULL), same all-null patch ---
|
||||||
|
before: [email protected], nickname=ash, roles=[USER], address=12 Park Road/Pune/411001
|
||||||
|
after : email=null, nickname=null, roles=null, address=null
|
||||||
|
|
||||||
|
--- whole objects and collections: OrderMapper (default) vs LenientOrderMapper (RETURN_DEFAULT) ---
|
||||||
|
strict.toDto(null) : null
|
||||||
|
lenient.toDto(null) : OrderDto[id=null, status=null, placedAt=null, lines=null, total=null]
|
||||||
|
strict order, lines = null : lines=null
|
||||||
|
lenient order, lines = null : lines=[]
|
||||||
|
strict.toDtos(null) : null
|
||||||
|
|
||||||
|
--- the generated patch() (IGNORE): every assignment is guarded ---
|
||||||
|
public void patch(CustomerPatch patch, Customer customer) {
|
||||||
|
if ( patch == null ) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ( patch.email() != null ) {
|
||||||
|
customer.setEmail( patch.email() );
|
||||||
|
}
|
||||||
|
if ( patch.nickname() != null ) {
|
||||||
|
customer.setNickname( patch.nickname() );
|
||||||
|
}
|
||||||
|
if ( patch.address() != null ) {
|
||||||
|
if ( customer.getAddress() == null ) {
|
||||||
|
customer.setAddress( new Address() );
|
||||||
|
}
|
||||||
|
addressMapper.update( patch.address(), customer.getAddress() );
|
||||||
|
}
|
||||||
|
if ( customer.getRoles() != null ) {
|
||||||
|
Set<String> set = patch.roles();
|
||||||
|
if ( set != null ) {
|
||||||
|
customer.getRoles().clear();
|
||||||
|
customer.getRoles().addAll( set );
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
Set<String> set = patch.roles();
|
||||||
|
if ( set != null ) {
|
||||||
|
customer.setRoles( new LinkedHashSet<String>( set ) );
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
# @MappingTarget updates: nested objects and collections are changed in place
|
||||||
|
|
||||||
|
|
||||||
|
--- patch only the city and the roles ---
|
||||||
|
address: [email protected], nickname=null, roles=[USER, ADMIN], address=12 Park Road/Mumbai/411001
|
||||||
|
same Address instance as before? true <- AddressMapper.update was called on it
|
||||||
|
same roles Set instance as before? true <- cleared and refilled, not replaced
|
||||||
|
another holder of the old roles Set now sees: [AUDITOR]
|
||||||
|
|
||||||
|
--- an immutable collection on the target ---
|
||||||
|
patching roles on a Set.of(...) target threw: java.lang.UnsupportedOperationException
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
# @BeforeMapping / @AfterMapping on a record target versus a bean target (call order, recorded)
|
||||||
|
|
||||||
|
|
||||||
|
--- target is a record: toRecord(Pair) ---
|
||||||
|
@BeforeMapping(source)
|
||||||
|
@BeforeMapping(source, @MappingTarget Boxed) target=Boxed[name=pen, qty=3]
|
||||||
|
@AfterMapping(source, @MappingTarget Boxed) target=Boxed[name=pen, qty=3]
|
||||||
|
@AfterMapping(source)
|
||||||
|
|
||||||
|
--- target is a mutable bean: toBean(Pair) ---
|
||||||
|
@BeforeMapping(source)
|
||||||
|
@BeforeMapping(source, @MappingTarget BoxedBean) name=null
|
||||||
|
BoxedBean.setName(pen)
|
||||||
|
BoxedBean.setQty(3)
|
||||||
|
@AfterMapping(source, @MappingTarget BoxedBean) name=pen
|
||||||
|
@AfterMapping(source)
|
||||||
|
|
||||||
|
--- the generated toRecord, which is why ---
|
||||||
|
public Probes.Boxed toRecord(Probes.Pair pair) {
|
||||||
|
beforeSource( pair );
|
||||||
|
|
||||||
|
if ( pair == null ) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
String name = null;
|
||||||
|
int qty = 0;
|
||||||
|
|
||||||
|
name = pair.name();
|
||||||
|
qty = pair.qty();
|
||||||
|
|
||||||
|
Probes.Boxed boxed = new Probes.Boxed( name, qty );
|
||||||
|
|
||||||
|
beforeRecord( pair, boxed );
|
||||||
|
|
||||||
|
afterRecord( pair, boxed );
|
||||||
|
afterSource( pair );
|
||||||
|
|
||||||
|
return boxed;
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
# @ObjectFactory returning a record: the factory's object is the result, and nothing is copied into it
|
||||||
|
|
||||||
|
source : Pair[name=pen, qty=3]
|
||||||
|
result : Boxed[name=from-the-factory, qty=0]
|
||||||
|
compiled with unmappedTargetPolicy = ERROR, and no error was reported
|
||||||
|
|
||||||
|
--- the generated method ---
|
||||||
|
public Probes.Boxed toRecord(Probes.Pair pair) {
|
||||||
|
if ( pair == null ) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
Probes.Boxed boxed = create( pair );
|
||||||
|
|
||||||
|
return boxed;
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
# Post-processing a record: @AfterMapping on its builder, or a @DecoratedWith decorator
|
||||||
|
|
||||||
|
|
||||||
|
--- ReceiptMapper: the hook receives ReceiptDto.Builder ---
|
||||||
|
ReceiptDto[orderId=100, total=32.50, summary=order 100: 2 line(s)]
|
||||||
|
public ReceiptDto toReceipt(Order order) {
|
||||||
|
if ( order == null ) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
ReceiptDto.Builder receiptDto = ReceiptDto.builder();
|
||||||
|
|
||||||
|
receiptDto.orderId( order.getId() );
|
||||||
|
receiptDto.total( orderMapper.sumOfLines( order.getLines() ) );
|
||||||
|
|
||||||
|
summarise( order, receiptDto );
|
||||||
|
|
||||||
|
return receiptDto.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
--- InvoiceMapper: @DecoratedWith, so two beans implement the interface ---
|
||||||
|
bean invoiceMapperImpl -> InvoiceMapperImpl
|
||||||
|
bean invoiceMapperImpl_ -> InvoiceMapperImpl_
|
||||||
|
injected InvoiceMapper (the @Primary one) : InvoiceDto[id=100, status=PAID, total=32.50, display=#100 [PAID] 32.50]
|
||||||
|
the generated delegate on its own : InvoiceDto[id=100, status=PAID, total=32.50, display=null]
|
||||||
|
|
||||||
|
--- the generated class that decorates ---
|
||||||
|
@Component
|
||||||
|
@Primary
|
||||||
|
public class InvoiceMapperImpl extends InvoiceMapperDecorator {
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
# @Context cycle tracking: it works for a bean target and cannot work for a record target
|
||||||
|
|
||||||
|
bean target + @Context CycleContext : ann.manager.manager is ann? true
|
||||||
|
record target + the same @Context : java.lang.StackOverflowError
|
||||||
|
|
||||||
|
--- why: the generated record mapping recurses BEFORE it can call the constructor ---
|
||||||
|
public Probes.EmployeeRecord toRecord(Probes.Employee employee, CycleContext context) {
|
||||||
|
Probes.EmployeeRecord target = context.lookup( employee, Probes.EmployeeRecord.class );
|
||||||
|
if ( target != null ) {
|
||||||
|
return target;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ( employee == null ) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
String name = null;
|
||||||
|
Probes.EmployeeRecord manager = null;
|
||||||
|
|
||||||
|
name = employee.name;
|
||||||
|
manager = toRecord( employee.manager, context );
|
||||||
|
|
||||||
|
Probes.EmployeeRecord employeeRecord = new Probes.EmployeeRecord( name, manager );
|
||||||
|
|
||||||
|
context.remember( employee, employeeRecord );
|
||||||
|
|
||||||
|
return employeeRecord;
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
# Testing a generated mapper without Spring: Mappers.getMapper versus constructing the Impl
|
||||||
|
|
||||||
|
|
||||||
|
--- 1. Mappers.getMapper(...) ---
|
||||||
|
AddressMapper (no dependencies) : AddressMapperImpl
|
||||||
|
CustomerMapper (uses = {...}) : RuntimeException
|
||||||
|
root cause : java.lang.NoSuchMethodException: com.ankurm.mapstruct.mapper.CustomerMapperImpl.<init>()
|
||||||
|
|
||||||
|
--- 2. new CustomerMapperImpl(new AddressMapperImpl(), new OrderMapperImpl()) ---
|
||||||
|
no Spring, no reflection : Asha Mehta
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
# @SpringJUnitConfig(classes = {the generated impls}): only the beans the mapper needs
|
||||||
|
|
||||||
|
bean definitions in the context : 11
|
||||||
|
bean names : org.springframework.context.annotation.internalConfigurationAnnotationProcessor, org.springframework.context.annotation.internalAutowiredAnnotationProcessor, org.springframework.context.annotation.internalCommonAnnotationProcessor, org.springframework.context.event.internalEventListenerProcessor, org.springframework.context.event.internalEventListenerFactory, customerMapperImpl, addressMapperImpl, orderMapperImpl, contextConfigurationMapperTest.PinnedClock, org.springframework.test.context.support.internalDynamicPropertyRegistrarBeanInitializer, clock
|
||||||
|
mapper injected as : CustomerMapperImpl
|
||||||
|
mapped : Asha Mehta
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
# @SpringBootTest: the same mapper, inside the whole application
|
||||||
|
|
||||||
|
bean definitions in the context : 153
|
||||||
|
includes the web controller : true
|
||||||
|
clock is the application's own : Z
|
||||||
|
mapped : Asha Mehta
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
# Property-level mapper test: change one entity property, expect exactly the right DTO component to change
|
||||||
|
|
||||||
|
id changes when its source changes : true
|
||||||
|
fullName changes when its source changes : true
|
||||||
|
email changes when its source changes : true
|
||||||
|
birthDate changes when its source changes : true
|
||||||
|
age changes when its source changes : true
|
||||||
|
nickname changes when its source changes : true
|
||||||
|
address changes when its source changes : true
|
||||||
|
roles changes when its source changes : true
|
||||||
|
attributes changes when its source changes : true
|
||||||
|
orders changes when its source changes : true
|
||||||
|
|
||||||
|
record components : [address, age, attributes, birthDate, email, fullName, id, nickname, orders, roles]
|
||||||
|
covered by a test : [address, age, attributes, birthDate, email, fullName, id, nickname, orders, roles]
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
# MockMvc against CustomerController: records in, records out, Jackson 3 in the middle
|
||||||
|
|
||||||
|
JSON library bean : tools.jackson.databind.json.JsonMapper
|
||||||
|
|
||||||
|
--- GET /customers/1 ---
|
||||||
|
{"id":1,"fullName":"Asha Mehta","email":"[email protected]","birthDate":"1990-06-15","age":36,"nickname":"anonymous","address":{"street":"12 Park Road","city":"Pune","zip":"411001","country":"IN"},"roles":["USER"],"attributes":{"newsletter":"yes"},"orders":[{"id":100,"status":"PAID","placedAt":"2026-02-01T10:00:00Z","lines":[{"sku":"BOOK-1","quantity":2,"unitPrice":12.50,"lineTotal":25.00},{"sku":"PEN-9","quantity":10,"unitPrice":0.75,"lineTotal":7.50}],"total":32.50}],"origin":"api"}
|
||||||
|
|
||||||
|
--- POST /customers (no nickname, no tier, no country) ---
|
||||||
|
{"id":2,"fullName":"Ravi Iyer","email":"[email protected]","birthDate":"1985-01-02","age":41,"nickname":"anonymous","address":{"street":"1 Lake View","city":"Chennai","zip":"600001","country":"IN"},"roles":["ADMIN"],"attributes":{},"orders":null,"origin":"api"}
|
||||||
|
|
||||||
|
--- PATCH /customers/1 {"nickname":"ash"} (every other component of the patch record is null) ---
|
||||||
|
{"id":1,"fullName":"Asha Mehta","email":"[email protected]","birthDate":"1990-06-15","age":36,"nickname":"ash","address":{"street":"12 Park Road","city":"Pune","zip":"411001","country":"IN"},"roles":["USER"],"attributes":{"newsletter":"yes"},"orders":[{"id":100,"status":"PAID","placedAt":"2026-02-01T10:00:00Z","lines":[{"sku":"BOOK-1","quantity":2,"unitPrice":12.50,"lineTotal":25.00},{"sku":"PEN-9","quantity":10,"unitPrice":0.75,"lineTotal":7.50}],"total":32.50}],"origin":"api"}
|
||||||
|
|
||||||
|
--- GET /customers/99 ---
|
||||||
|
status 404
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
# INDICATIVE timings: hand-written vs MapStruct vs Dozer (setter-based beans)
|
||||||
|
|
||||||
|
Java 25.0.3+9-LTS-195, one JVM, 200000 iterations per round, best of 5 rounds after a 50000-iteration warm-up
|
||||||
|
mapping: Customer -> CustomerView (1 address, 1 order with 2 lines, 1 set, 1 map)
|
||||||
|
|
||||||
|
hand-written 95 ns/op
|
||||||
|
MapStruct 1.6.3 92 ns/op (1.0x hand-written)
|
||||||
|
Dozer 7.0.0 11498 ns/op (122x hand-written)
|
||||||
|
|
||||||
|
first call, cold: MapStruct 0.13 ms, Dozer 80.9 ms
|
||||||
|
results equal: true
|
||||||
|
|
||||||
|
Indicative only. Timings vary between runs and machines; do not quote them as a benchmark result.
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
# A record target component nobody mapped (unmappedTargetPolicy = ERROR)
|
||||||
|
|
||||||
|
$ javac -d target/broken/unmapped-target -cp $MAPSTRUCT_JAR -processorpath $MAPSTRUCT_PROCESSOR_JAR src/broken/unmapped-target/*.java
|
||||||
|
src/broken/unmapped-target/BrokenMapper.java:12: error: Unmapped target property: "note".
|
||||||
|
Dst map(Src src);
|
||||||
|
^
|
||||||
|
1 error
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
# Two methods can produce the same property
|
||||||
|
|
||||||
|
$ javac -d target/broken/ambiguous -cp $MAPSTRUCT_JAR -processorpath $MAPSTRUCT_PROCESSOR_JAR src/broken/ambiguous/*.java
|
||||||
|
src/broken/ambiguous/BrokenMapper.java:13: error: Ambiguous mapping methods found for mapping property "rokenMapper.Money price" to String: String plain(rokenMapper.Money m), String formatted(rokenMapper.Money m). See https://mapstruct.org/faq/#ambiguous for more info.
|
||||||
|
Dst map(Src src);
|
||||||
|
^
|
||||||
|
1 error
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
# A typo in @Mapping(source = ...)
|
||||||
|
|
||||||
|
$ javac -d target/broken/missing-source -cp $MAPSTRUCT_JAR -processorpath $MAPSTRUCT_PROCESSOR_JAR src/broken/missing-source/*.java
|
||||||
|
src/broken/missing-source/BrokenMapper.java:12: error: No property named "nmae" exists in source parameter(s). Did you mean "name"?
|
||||||
|
@Mapping(source = "nmae", target = "title")
|
||||||
|
^
|
||||||
|
1 error
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
# @MappingTarget on a record compiles, with unmappedTargetPolicy = ERROR, and generates this
|
||||||
|
|
||||||
|
$ javac -d target/broken/update-record -s target/broken/update-record -cp $MAPSTRUCT_JAR -processorpath $MAPSTRUCT_PROCESSOR_JAR src/broken/update-record/*.java
|
||||||
|
(exit status: 0)
|
||||||
|
|
||||||
|
$ cat target/broken/update-record/BrokenMapperImpl.java (header and imports trimmed)
|
||||||
|
public class BrokenMapperImpl implements BrokenMapper {
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void update(BrokenMapper.Patch patch, BrokenMapper.Dst target) {
|
||||||
|
if ( patch == null ) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
# No way to turn one type into another
|
||||||
|
|
||||||
|
$ javac -d target/broken/cannot-map -cp $MAPSTRUCT_JAR -processorpath $MAPSTRUCT_PROCESSOR_JAR src/broken/cannot-map/*.java
|
||||||
|
src/broken/cannot-map/BrokenMapper.java:17: error: Can't map property "rokenMapper.Tag tag" to "LocalDate tag". Consider to declare/implement a mapping method: "LocalDate map(rokenMapper.Tag value)".
|
||||||
|
Dst map(Src src);
|
||||||
|
^
|
||||||
|
1 error
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
# @DecoratedWith with a constructor-injected delegate (componentModel = spring)
|
||||||
|
|
||||||
|
$ javac -d target/broken/decorator-constructor -cp $MAPSTRUCT_JAR:$SPRING_JARS -processorpath $MAPSTRUCT_PROCESSOR_JAR src/broken/decorator-constructor/*.java
|
||||||
|
target/broken/decorator-constructor/BrokenMapperImpl.java:13: error: constructor BrokenMapperDecorator in class BrokenMapperDecorator cannot be applied to given types;
|
||||||
|
public class BrokenMapperImpl extends BrokenMapperDecorator {
|
||||||
|
^
|
||||||
|
required: BrokenMapper
|
||||||
|
found: no arguments
|
||||||
|
reason: actual and formal argument lists differ in length
|
||||||
|
1 error
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
# The processor jar on the plain classpath: javac 25.0.3 compiles without generating anything
|
||||||
|
|
||||||
|
$ javac -d target/broken/processor-not-run -cp $MAPSTRUCT_JAR:$MAPSTRUCT_PROCESSOR_JAR src/broken/processor-not-run/UsesMapper.java
|
||||||
|
(exit status: 0)
|
||||||
|
|
||||||
|
$ ls target/broken/processor-not-run
|
||||||
|
UsesMapper$Dst.class
|
||||||
|
UsesMapper$Src.class
|
||||||
|
UsesMapper.class
|
||||||
|
|
||||||
|
$ java -cp $MAPSTRUCT_JAR:target/broken/processor-not-run UsesMapper
|
||||||
|
Exception in thread "main" java.lang.RuntimeException: java.lang.ClassNotFoundException: Cannot find implementation for UsesMapper
|
||||||
|
at org.mapstruct.factory.Mappers.getMapper(Mappers.java:61)
|
||||||
|
at UsesMapper.main(UsesMapper.java:15)
|
||||||
|
Caused by: java.lang.ClassNotFoundException: Cannot find implementation for UsesMapper
|
||||||
|
|
||||||
|
$ javac -proc:full -d target/broken/processor-not-run -cp $MAPSTRUCT_JAR:$MAPSTRUCT_PROCESSOR_JAR src/broken/processor-not-run/UsesMapper.java
|
||||||
|
(exit status: 0)
|
||||||
|
$ ls target/broken/processor-not-run
|
||||||
|
UsesMapper$Dst.class
|
||||||
|
UsesMapper$Src.class
|
||||||
|
UsesMapper.class
|
||||||
|
UsesMapperImpl.class
|
||||||
|
UsesMapperImpl.java
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
# Lombok + MapStruct + a record target: what the processor path order and lombok-mapstruct-binding change
|
||||||
|
|
||||||
|
--- MapStruct first, Lombok second, no binding ---
|
||||||
|
$ javac -d target/broken/lombok -cp $MAPSTRUCT_JAR:$LOMBOK_JAR -processorpath $MAPSTRUCT_PROCESSOR_JAR:$LOMBOK_JAR src/broken/lombok/LombokMapper.java
|
||||||
|
src\broken\lombok\LombokMapper.java:19: error: Unmapped target properties: "name, age".
|
||||||
|
PersonDto map(Person person);
|
||||||
|
^
|
||||||
|
1 error
|
||||||
|
(exit status: 1)
|
||||||
|
LombokMapperImpl generated: no
|
||||||
|
|
||||||
|
--- Lombok first, MapStruct second, no binding ---
|
||||||
|
$ javac -d target/broken/lombok -cp $MAPSTRUCT_JAR:$LOMBOK_JAR -processorpath $LOMBOK_JAR:$MAPSTRUCT_PROCESSOR_JAR src/broken/lombok/LombokMapper.java
|
||||||
|
(exit status: 0)
|
||||||
|
LombokMapperImpl generated: yes
|
||||||
|
|
||||||
|
--- MapStruct first, then Lombok and lombok-mapstruct-binding ---
|
||||||
|
$ javac -d target/broken/lombok -cp $MAPSTRUCT_JAR:$LOMBOK_JAR -processorpath $MAPSTRUCT_PROCESSOR_JAR:$LOMBOK_JAR:$BINDING_JAR src/broken/lombok/LombokMapper.java
|
||||||
|
(exit status: 0)
|
||||||
|
LombokMapperImpl generated: yes
|
||||||
|
|
||||||
|
--- Lombok, binding, then MapStruct ---
|
||||||
|
$ javac -d target/broken/lombok -cp $MAPSTRUCT_JAR:$LOMBOK_JAR -processorpath $LOMBOK_JAR:$BINDING_JAR:$MAPSTRUCT_PROCESSOR_JAR src/broken/lombok/LombokMapper.java
|
||||||
|
(exit status: 0)
|
||||||
|
LombokMapperImpl generated: yes
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
# mapstruct-processor 1.6.3: what the jar itself declares
|
||||||
|
|
||||||
|
--- META-INF/services/javax.annotation.processing.Processor (comments dropped) ---
|
||||||
|
org.mapstruct.ap.MappingProcessor
|
||||||
|
|
||||||
|
--- Implementation-Version in the manifest ---
|
||||||
|
Implementation-Version: 1.6.3
|
||||||
|
|
||||||
|
--- options the processor accepts (-Akey=value), from @SupportedOptions via javap ---
|
||||||
|
mapstruct.alwaysGenerateServicesFile
|
||||||
|
mapstruct.defaultComponentModel
|
||||||
|
mapstruct.defaultInjectionStrategy
|
||||||
|
mapstruct.disableBuilders
|
||||||
|
mapstruct.nullValueIterableMappingStrategy
|
||||||
|
mapstruct.nullValueMapMappingStrategy
|
||||||
|
mapstruct.suppressGeneratorTimestamp
|
||||||
|
mapstruct.suppressGeneratorVersionInfoComment
|
||||||
|
mapstruct.unmappedSourcePolicy
|
||||||
|
mapstruct.unmappedTargetPolicy
|
||||||
|
mapstruct.verbose
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project xmlns="http://maven.apache.org/POM/4.0.0"
|
||||||
|
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||||
|
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||||
|
<modelVersion>4.0.0</modelVersion>
|
||||||
|
|
||||||
|
<parent>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-parent</artifactId>
|
||||||
|
<version>4.1.2</version>
|
||||||
|
<relativePath/>
|
||||||
|
</parent>
|
||||||
|
|
||||||
|
<groupId>com.ankurm</groupId>
|
||||||
|
<artifactId>mapstruct</artifactId>
|
||||||
|
<version>1.0.0</version>
|
||||||
|
<name>mapstruct</name>
|
||||||
|
<description>MapStruct on Spring Boot 4: compile-time DTO mapping with Java records</description>
|
||||||
|
|
||||||
|
<properties>
|
||||||
|
<java.version>25</java.version>
|
||||||
|
<mapstruct.version>1.6.3</mapstruct.version>
|
||||||
|
<dozer.version>7.0.0</dozer.version>
|
||||||
|
</properties>
|
||||||
|
|
||||||
|
<dependencies>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-webmvc</artifactId>
|
||||||
|
</dependency>
|
||||||
|
<!-- Only the annotations live here. The code generator is the processor below. -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.mapstruct</groupId>
|
||||||
|
<artifactId>mapstruct</artifactId>
|
||||||
|
<version>${mapstruct.version}</version>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-test</artifactId>
|
||||||
|
<scope>test</scope>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-webmvc-test</artifactId>
|
||||||
|
<scope>test</scope>
|
||||||
|
</dependency>
|
||||||
|
<!-- Only for the indicative timing comparison in the tests. Dozer is a runtime-reflection mapper. -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>com.github.dozermapper</groupId>
|
||||||
|
<artifactId>dozer-core</artifactId>
|
||||||
|
<version>${dozer.version}</version>
|
||||||
|
<scope>test</scope>
|
||||||
|
</dependency>
|
||||||
|
</dependencies>
|
||||||
|
|
||||||
|
<build>
|
||||||
|
<plugins>
|
||||||
|
<plugin>
|
||||||
|
<groupId>org.apache.maven.plugins</groupId>
|
||||||
|
<artifactId>maven-compiler-plugin</artifactId>
|
||||||
|
<configuration>
|
||||||
|
<!-- The processor goes HERE and not in the dependencies. No Lombok in this module, so no
|
||||||
|
lombok-mapstruct-binding path is needed (the article says what happens when there is). -->
|
||||||
|
<annotationProcessorPaths>
|
||||||
|
<path>
|
||||||
|
<groupId>org.mapstruct</groupId>
|
||||||
|
<artifactId>mapstruct-processor</artifactId>
|
||||||
|
<version>${mapstruct.version}</version>
|
||||||
|
</path>
|
||||||
|
</annotationProcessorPaths>
|
||||||
|
</configuration>
|
||||||
|
</plugin>
|
||||||
|
<plugin>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-maven-plugin</artifactId>
|
||||||
|
</plugin>
|
||||||
|
</plugins>
|
||||||
|
</build>
|
||||||
|
</project>
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Facts read straight out of the MapStruct processor jar (output/25).
|
||||||
|
set -euo pipefail
|
||||||
|
cd "$(dirname "$0")/.."
|
||||||
|
MS=${MAPSTRUCT_VERSION:-1.6.3}
|
||||||
|
JAR=$HOME/.m2/repository/org/mapstruct/mapstruct-processor/$MS/mapstruct-processor-$MS.jar
|
||||||
|
mkdir -p output target/facts
|
||||||
|
rm -rf target/facts/p && mkdir -p target/facts/p
|
||||||
|
unzip -o -q "$JAR" 'org/mapstruct/ap/MappingProcessor*' -d target/facts/p
|
||||||
|
{
|
||||||
|
echo "# mapstruct-processor $MS: what the jar itself declares"
|
||||||
|
echo
|
||||||
|
echo "--- META-INF/services/javax.annotation.processing.Processor (comments dropped) ---"
|
||||||
|
unzip -p "$JAR" META-INF/services/javax.annotation.processing.Processor | tr -d '\r' | grep -v '^#' | grep -v '^$'
|
||||||
|
echo
|
||||||
|
echo "--- Implementation-Version in the manifest ---"
|
||||||
|
unzip -p "$JAR" META-INF/MANIFEST.MF | tr -d '\r' | grep '^Implementation-Version'
|
||||||
|
echo
|
||||||
|
echo "--- options the processor accepts (-Akey=value), from @SupportedOptions via javap ---"
|
||||||
|
javap -v -cp target/facts/p org.mapstruct.ap.MappingProcessor | tr -d '\r' | grep -o 'Utf8 *mapstruct\.[A-Za-z.]*' | sed 's/Utf8 *//' | sort -u
|
||||||
|
} > output/25-processor-jar-facts.txt
|
||||||
|
echo "wrote output/25-processor-jar-facts.txt"
|
||||||
@@ -0,0 +1,130 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Compiles the deliberately broken sources under src/broken/ with javac and commits the compiler's own words:
|
||||||
|
# 17 unmapped target property 18 ambiguous mapping methods 19 missing source property
|
||||||
|
# 20 @MappingTarget on a record (compiles!) 21 no way to map one type to another
|
||||||
|
# 22 @DecoratedWith + constructor 23 processor on the plain classpath (JDK 23+)
|
||||||
|
# 24 Lombok before/after MapStruct, with and without lombok-mapstruct-binding
|
||||||
|
# Each step also greps for the expected message and fails the script if it is not there, so a MapStruct
|
||||||
|
# upgrade that rewords an error is noticed. Needs a JDK 25 and the jars from one `mvn test` (they come from ~/.m2).
|
||||||
|
set -uo pipefail
|
||||||
|
cd "$(dirname "$0")/.."
|
||||||
|
mkdir -p output target/broken
|
||||||
|
|
||||||
|
M2=${M2_REPO:-$HOME/.m2/repository}
|
||||||
|
MS=${MAPSTRUCT_VERSION:-1.6.3}
|
||||||
|
LOMBOK=${LOMBOK_VERSION:-1.18.48}
|
||||||
|
SPRING=${SPRING_VERSION:-7.0.10}
|
||||||
|
|
||||||
|
native() { if command -v cygpath >/dev/null 2>&1; then cygpath -m "$1"; else echo "$1"; fi; }
|
||||||
|
case "$(uname -s)" in MINGW*|MSYS*|CYGWIN*) SEP=';' ;; *) SEP=':' ;; esac
|
||||||
|
|
||||||
|
MSJ=$(native "$M2/org/mapstruct/mapstruct/$MS/mapstruct-$MS.jar")
|
||||||
|
MSP=$(native "$M2/org/mapstruct/mapstruct-processor/$MS/mapstruct-processor-$MS.jar")
|
||||||
|
LOM=$(native "$M2/org/projectlombok/lombok/$LOMBOK/lombok-$LOMBOK.jar")
|
||||||
|
BIND=$(native "$M2/org/projectlombok/lombok-mapstruct-binding/0.2.0/lombok-mapstruct-binding-0.2.0.jar")
|
||||||
|
SPR="$(native "$M2/org/springframework/spring-context/$SPRING/spring-context-$SPRING.jar")$SEP$(native "$M2/org/springframework/spring-beans/$SPRING/spring-beans-$SPRING.jar")$SEP$(native "$M2/org/springframework/spring-core/$SPRING/spring-core-$SPRING.jar")"
|
||||||
|
|
||||||
|
fail=0
|
||||||
|
|
||||||
|
# capture <file> <title> <expected text or ''> <display command> -- <real command...>
|
||||||
|
capture() {
|
||||||
|
local file=$1 title=$2 expect=$3 shown=$4; shift 5
|
||||||
|
local out
|
||||||
|
out=$("$@" 2>&1 | sed 's/\r$//; s#\\#/#g')
|
||||||
|
{
|
||||||
|
echo "# $title"
|
||||||
|
echo
|
||||||
|
echo "\$ $shown"
|
||||||
|
printf '%s\n' "$out"
|
||||||
|
} > "output/$file"
|
||||||
|
if [ -n "$expect" ] && ! grep -qF -- "$expect" "output/$file"; then
|
||||||
|
echo "MISSING in output/$file: $expect" >&2
|
||||||
|
fail=1
|
||||||
|
fi
|
||||||
|
echo "wrote output/$file"
|
||||||
|
}
|
||||||
|
|
||||||
|
javac_case() { # <case dir> -> real javac invocation with the processor on -processorpath
|
||||||
|
local c=$1; rm -rf "target/broken/$c"; mkdir -p "target/broken/$c"
|
||||||
|
echo "javac -d target/broken/$c -cp \$MAPSTRUCT_JAR -processorpath \$MAPSTRUCT_PROCESSOR_JAR src/broken/$c/*.java"
|
||||||
|
}
|
||||||
|
|
||||||
|
for spec in \
|
||||||
|
"17-error-unmapped-target.txt|unmapped-target|Unmapped target property: \"note\"|A record target component nobody mapped (unmappedTargetPolicy = ERROR)" \
|
||||||
|
"18-error-ambiguous-mapping-methods.txt|ambiguous|Ambiguous mapping methods found for mapping property|Two methods can produce the same property" \
|
||||||
|
"19-error-missing-source-property.txt|missing-source|No property named \"nmae\" exists in source parameter(s)|A typo in @Mapping(source = ...)" \
|
||||||
|
"21-error-cannot-map-type.txt|cannot-map|Can't map property|No way to turn one type into another"; do
|
||||||
|
IFS='|' read -r file case expect title <<<"$spec"
|
||||||
|
shown=$(javac_case "$case")
|
||||||
|
capture "$file" "$title" "$expect" "$shown" -- \
|
||||||
|
javac -d "target/broken/$case" -cp "$MSJ" -processorpath "$MSP" src/broken/$case/*.java
|
||||||
|
done
|
||||||
|
|
||||||
|
# 20: @MappingTarget on a record is NOT a compile error: read what was generated
|
||||||
|
c=update-record; rm -rf "target/broken/$c"; mkdir -p "target/broken/$c"
|
||||||
|
{
|
||||||
|
echo "# @MappingTarget on a record compiles, with unmappedTargetPolicy = ERROR, and generates this"
|
||||||
|
echo
|
||||||
|
echo "\$ javac -d target/broken/$c -s target/broken/$c -cp \$MAPSTRUCT_JAR -processorpath \$MAPSTRUCT_PROCESSOR_JAR src/broken/$c/*.java"
|
||||||
|
javac -d "target/broken/$c" -s "target/broken/$c" -cp "$MSJ" -processorpath "$MSP" src/broken/$c/*.java 2>&1 | sed 's/␍$//'
|
||||||
|
echo "(exit status: ${PIPESTATUS[0]})"
|
||||||
|
echo
|
||||||
|
echo "\$ cat target/broken/$c/BrokenMapperImpl.java (header and imports trimmed)"
|
||||||
|
sed -n '/^public class/,$p' "target/broken/$c/BrokenMapperImpl.java" | sed 's/␍$//'
|
||||||
|
} > output/20-update-method-on-a-record-compiles-to-a-no-op.txt
|
||||||
|
echo "wrote output/20-update-method-on-a-record-compiles-to-a-no-op.txt"
|
||||||
|
grep -q 'public void update' output/20-update-method-on-a-record-compiles-to-a-no-op.txt || { echo "MISSING update in 20" >&2; fail=1; }
|
||||||
|
|
||||||
|
# 22: the decorator needs the Spring annotations on the classpath
|
||||||
|
c=decorator-constructor; rm -rf "target/broken/$c"; mkdir -p "target/broken/$c"
|
||||||
|
capture 22-error-decorator-constructor.txt "@DecoratedWith with a constructor-injected delegate (componentModel = spring)" \
|
||||||
|
"constructor BrokenMapperDecorator in class BrokenMapperDecorator cannot be applied to given types" \
|
||||||
|
"javac -d target/broken/$c -cp \$MAPSTRUCT_JAR:\$SPRING_JARS -processorpath \$MAPSTRUCT_PROCESSOR_JAR src/broken/$c/*.java" -- \
|
||||||
|
javac -d "target/broken/$c" -cp "$MSJ$SEP$SPR" -processorpath "$MSP" src/broken/$c/*.java
|
||||||
|
|
||||||
|
# 23: processor jar on the plain classpath, no -processorpath, no -proc:full
|
||||||
|
c=processor-not-run; rm -rf "target/broken/$c"; mkdir -p "target/broken/$c"
|
||||||
|
{
|
||||||
|
echo "# The processor jar on the plain classpath: javac $(javac -version 2>&1 | awk '{print $2}') compiles without generating anything"
|
||||||
|
echo
|
||||||
|
echo "\$ javac -d target/broken/$c -cp \$MAPSTRUCT_JAR:\$MAPSTRUCT_PROCESSOR_JAR src/broken/$c/UsesMapper.java"
|
||||||
|
javac -d "target/broken/$c" -cp "$MSJ$SEP$MSP" src/broken/$c/UsesMapper.java 2>&1 | sed 's/\r$//'
|
||||||
|
echo "(exit status: ${PIPESTATUS[0]})"
|
||||||
|
echo
|
||||||
|
echo "\$ ls target/broken/$c"
|
||||||
|
ls "target/broken/$c" | sed 's/\r$//'
|
||||||
|
echo
|
||||||
|
echo "\$ java -cp \$MAPSTRUCT_JAR:target/broken/$c UsesMapper"
|
||||||
|
java -cp "$MSJ$SEP""target/broken/$c" UsesMapper 2>&1 | sed 's/\r$//' | head -4
|
||||||
|
echo
|
||||||
|
rm -rf "target/broken/$c"; mkdir -p "target/broken/$c"
|
||||||
|
echo "\$ javac -proc:full -d target/broken/$c -cp \$MAPSTRUCT_JAR:\$MAPSTRUCT_PROCESSOR_JAR src/broken/$c/UsesMapper.java"
|
||||||
|
javac -proc:full -d "target/broken/$c" -cp "$MSJ$SEP$MSP" src/broken/$c/UsesMapper.java 2>&1 | sed 's/\r$//'
|
||||||
|
echo "(exit status: ${PIPESTATUS[0]})"
|
||||||
|
echo "\$ ls target/broken/$c"
|
||||||
|
ls "target/broken/$c" | sed 's/\r$//'
|
||||||
|
} > output/23-processor-on-the-plain-classpath.txt
|
||||||
|
echo "wrote output/23-processor-on-the-plain-classpath.txt"
|
||||||
|
grep -q 'UsesMapperImpl.class' output/23-processor-on-the-plain-classpath.txt || { echo "MISSING UsesMapperImpl in 23" >&2; fail=1; }
|
||||||
|
|
||||||
|
# 24: Lombok and MapStruct ordering
|
||||||
|
c=lombok
|
||||||
|
run_lombok() { # <label> <processorpath>
|
||||||
|
rm -rf "target/broken/$c"; mkdir -p "target/broken/$c"
|
||||||
|
echo
|
||||||
|
echo "--- $1 ---"
|
||||||
|
echo "\$ javac -d target/broken/$c -cp \$MAPSTRUCT_JAR:\$LOMBOK_JAR -processorpath $3 src/broken/$c/LombokMapper.java"
|
||||||
|
javac -d "target/broken/$c" -cp "$MSJ$SEP$LOM" -processorpath "$2" src/broken/$c/LombokMapper.java 2>&1 | sed 's/\r$//; s#\\#/#g' | grep -v '^WARNING:'
|
||||||
|
echo "(exit status: ${PIPESTATUS[0]})"
|
||||||
|
echo "LombokMapperImpl generated: $([ -f target/broken/$c/LombokMapperImpl.class ] && echo yes || echo no)"
|
||||||
|
}
|
||||||
|
{
|
||||||
|
echo "# Lombok + MapStruct + a record target: what the processor path order and lombok-mapstruct-binding change"
|
||||||
|
run_lombok "MapStruct first, Lombok second, no binding" "$MSP$SEP$LOM" '$MAPSTRUCT_PROCESSOR_JAR:$LOMBOK_JAR'
|
||||||
|
run_lombok "Lombok first, MapStruct second, no binding" "$LOM$SEP$MSP" '$LOMBOK_JAR:$MAPSTRUCT_PROCESSOR_JAR'
|
||||||
|
run_lombok "MapStruct first, then Lombok and lombok-mapstruct-binding" "$MSP$SEP$LOM$SEP$BIND" '$MAPSTRUCT_PROCESSOR_JAR:$LOMBOK_JAR:$BINDING_JAR'
|
||||||
|
run_lombok "Lombok, binding, then MapStruct" "$LOM$SEP$BIND$SEP$MSP" '$LOMBOK_JAR:$BINDING_JAR:$MAPSTRUCT_PROCESSOR_JAR'
|
||||||
|
} > output/24-lombok-processor-order.txt
|
||||||
|
echo "wrote output/24-lombok-processor-order.txt"
|
||||||
|
|
||||||
|
exit $fail
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Regenerates every file under output/.
|
||||||
|
#
|
||||||
|
# ./scripts/run-all.sh
|
||||||
|
#
|
||||||
|
# Needs a JDK 25 and Maven 3.9 (and network access to Maven Central once). Transcripts 01-15 and the timing
|
||||||
|
# comparison 16 are written by the test suite, so each figure in the article is an assertion that fails the
|
||||||
|
# build if it stops being true. 17-24 are the compiler's own words, captured from the broken sources in
|
||||||
|
# src/broken/; 25 is read out of the processor jar. 16 is a timing and therefore differs from run to run.
|
||||||
|
set -euo pipefail
|
||||||
|
cd "$(dirname "$0")/.."
|
||||||
|
|
||||||
|
echo "== test suite (transcripts 01-16)"
|
||||||
|
mvn -B test
|
||||||
|
|
||||||
|
echo "== compile errors and processor behaviour (17-24)"
|
||||||
|
./scripts/compile-errors.sh
|
||||||
|
|
||||||
|
echo "== processor jar facts (25)"
|
||||||
|
./scripts/capture-facts.sh
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "output:"
|
||||||
|
ls -1 output
|
||||||
@@ -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; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
# startup-runners
|
||||||
|
|
||||||
|
Companion project for the article **[CommandLineRunner vs ApplicationRunner vs ApplicationReadyEvent in Spring Boot 4](https://ankurm.com/commandlinerunner-vs-applicationrunner-vs-applicationreadyevent-spring-boot-4/)** on **[ankurm.com](https://ankurm.com)**.
|
||||||
|
|
||||||
|
Every console block quoted in the article came out of `output/`. Transcripts 00-14 are written by the test
|
||||||
|
suite (so a claim that stops being true turns the build red); 15-17 are read out of the Spring Boot and Spring
|
||||||
|
Framework sources and jars by `scripts/capture-facts.sh`.
|
||||||
|
|
||||||
|
There is deliberately **no `docs/` folder**: the deeper material lives in collapsible sections inside the article.
|
||||||
|
|
||||||
|
## Versions
|
||||||
|
|
||||||
|
| | |
|
||||||
|
|---|---|
|
||||||
|
| Spring Boot | 4.1.2 (GA 2026-10-22 on Maven Central) |
|
||||||
|
| Spring Framework | 7.0.10 |
|
||||||
|
| JDK | 25 (25.0.3) |
|
||||||
|
| Maven | 3.9 |
|
||||||
|
| Sources read for contrast | Spring Boot 3.0.13 and 3.1.12 (`callRunners` only, not run) |
|
||||||
|
|
||||||
|
## Quickstart
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export JAVA_HOME=/path/to/jdk-25
|
||||||
|
mvn test # runs the scenarios and rewrites output/00-14
|
||||||
|
./scripts/run-all.sh # everything, including 15-17 (needs network access to Maven Central once)
|
||||||
|
```
|
||||||
|
|
||||||
|
Transcript 03 (four runners without `@Order`) is the one file that is allowed to differ between runs: that is its point.
|
||||||
|
|
||||||
|
## Source layout
|
||||||
|
|
||||||
|
Each scenario is its own package with its own `@SpringBootApplication`.
|
||||||
|
|
||||||
|
| Package | What it holds |
|
||||||
|
|---|---|
|
||||||
|
| `hello/` | the smallest example: a `CommandLineRunner`, an `ApplicationRunner`, an `ApplicationReadyEvent` listener |
|
||||||
|
| `order/` | every startup hook plus `@PostConstruct`, `InitializingBean`, `SmartLifecycle`, printing the sequence and the readiness state |
|
||||||
|
| `ordering/`, `ties/` | `@Order` on classes, `@Bean` methods and `Ordered`; four runners with no order at all |
|
||||||
|
| `args/` | the same command line seen as `String[]` and as `ApplicationArguments` |
|
||||||
|
| `failure/` | a runner that throws, an `ApplicationReadyEvent` listener that throws, `ExitCodeGenerator`, a checked exception (profiles) |
|
||||||
|
| `blocking/` | a runner that blocks (web app, actuator probes) and one that hands its work to a virtual thread |
|
||||||
|
| `lifecycle/`, `lifestartfails/`, `lifeforgetful/` | `SmartLifecycle` phases, a `start()` that throws, an `isRunning()` that never says true |
|
||||||
|
| `slices/` | startup hooks declared four ways, for the `@SpringBootTest` and `@WebMvcTest` scenarios |
|
||||||
|
| `support/` | `Trace`, the in-memory log the tests print |
|
||||||
|
|
||||||
|
## Captured output
|
||||||
|
|
||||||
|
| File | What it shows |
|
||||||
|
|---|---|
|
||||||
|
| `00-hello.txt` | the three hooks of `HelloApp`, started with a real command line |
|
||||||
|
| `01-startup-hooks-in-order.txt` | every hook, in order, non-web and web, with the readiness state each one sees |
|
||||||
|
| `02-order-annotation-and-ordered.txt` | both runner kinds sorted together by `@Order` / `Ordered`; a bean that is both kinds |
|
||||||
|
| `03-ties-have-no-promised-order.txt` | four unordered runners in 8 fresh JVMs |
|
||||||
|
| `04-arguments.txt` | `String[]` versus `ApplicationArguments` |
|
||||||
|
| `05-runner-failure.txt` | a throwing runner: events, log, exit codes (1, 42), checked-exception wrapping |
|
||||||
|
| `06-ready-listener-failure.txt` | a throwing `ApplicationReadyEvent` listener, non-web and web |
|
||||||
|
| `07-blocking-runner-readiness.txt` | a blocking runner: port open, liveness UP, readiness 503 |
|
||||||
|
| `08-async-runner.txt` | a runner that returns at once and a background task that fails |
|
||||||
|
| `09-smartlifecycle-phases.txt` | start in ascending phase, stop in descending phase, async stop, a bean that is not auto-started |
|
||||||
|
| `10-lifecycle-beans-in-a-web-app.txt` | every `SmartLifecycle` bean in a web app with its phase |
|
||||||
|
| `11-smartlifecycle-start-fails.txt` | `start()` throws: no started event, no runner |
|
||||||
|
| `12-smartlifecycle-forgot-isrunning.txt` | `isRunning()` always false: `stop()` never called |
|
||||||
|
| `13-spring-boot-test-runs-the-hooks.txt` | what `@SpringBootTest` runs |
|
||||||
|
| `14-webmvctest-slice-hooks.txt` | what `@WebMvcTest` runs |
|
||||||
|
| `15-callrunners-source.txt` | `SpringApplication.run`, `callRunners`, `callRunner` and the run listener, from the 4.1.2 sources, plus 3.0.13 and 3.1.12 |
|
||||||
|
| `16-javap-callrunners.txt` | the compiled `callRunners` and the runner interfaces, read with `javap` |
|
||||||
|
| `17-lifecycleprocessor-source.txt` | when `DefaultLifecycleProcessor` calls `start()` and `stop()` |
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
# HelloApp started with: --greeting=hi --verbose notes.txt
|
||||||
|
|
||||||
|
hello: CommandLineRunner args = [--greeting=hi, --verbose, notes.txt]
|
||||||
|
hello: ApplicationRunner options = [greeting, verbose], non-option args = [notes.txt]
|
||||||
|
hello: ApplicationReadyEvent listener
|
||||||
|
process exit code: 0
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
# Every startup hook in the order Spring Boot 4.1.2 really calls it
|
||||||
|
|
||||||
|
|
||||||
|
--- non-web application ---
|
||||||
|
@PostConstruct
|
||||||
|
InitializingBean.afterPropertiesSet
|
||||||
|
SmartInitializingSingleton.afterSingletonsInstantiated
|
||||||
|
SmartLifecycle.start (default phase)
|
||||||
|
ContextRefreshedEvent
|
||||||
|
ApplicationStartedEvent
|
||||||
|
AvailabilityChangeEvent -> CORRECT
|
||||||
|
CommandLineRunner (readiness seen from inside: REFUSING_TRAFFIC, liveness: CORRECT)
|
||||||
|
ApplicationRunner (readiness seen from inside: REFUSING_TRAFFIC)
|
||||||
|
ApplicationReadyEvent (readiness seen from inside: REFUSING_TRAFFIC)
|
||||||
|
AvailabilityChangeEvent -> ACCEPTING_TRAFFIC
|
||||||
|
|
||||||
|
--- servlet web application (Tomcat on a random port) ---
|
||||||
|
@PostConstruct
|
||||||
|
InitializingBean.afterPropertiesSet
|
||||||
|
SmartInitializingSingleton.afterSingletonsInstantiated
|
||||||
|
WebServerInitializedEvent (port is open)
|
||||||
|
SmartLifecycle.start (default phase)
|
||||||
|
ContextRefreshedEvent
|
||||||
|
ApplicationStartedEvent
|
||||||
|
AvailabilityChangeEvent -> CORRECT
|
||||||
|
CommandLineRunner (readiness seen from inside: REFUSING_TRAFFIC, liveness: CORRECT)
|
||||||
|
ApplicationRunner (readiness seen from inside: REFUSING_TRAFFIC)
|
||||||
|
ApplicationReadyEvent (readiness seen from inside: REFUSING_TRAFFIC)
|
||||||
|
AvailabilityChangeEvent -> ACCEPTING_TRAFFIC
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
# CommandLineRunner and ApplicationRunner beans sorted together by @Order / Ordered
|
||||||
|
|
||||||
|
(declaration order in OrderingApp.java is NOT the order below)
|
||||||
|
|
||||||
|
CommandLineRunner @Order(HIGHEST_PRECEDENCE)
|
||||||
|
ApplicationRunner @Order(1) on the class
|
||||||
|
CommandLineRunner @Order(2) on the class
|
||||||
|
ApplicationRunner implements Ordered, getOrder() = 3
|
||||||
|
Both @Order(4) -> ApplicationRunner.run
|
||||||
|
Both @Order(4) -> CommandLineRunner.run
|
||||||
|
CommandLineRunner @Order(5) on a @Bean method
|
||||||
|
ApplicationRunner no order at all (lowest precedence)
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
# Four runners without @Order: the order chosen by each of 8 fresh JVMs
|
||||||
|
|
||||||
|
JVM 1: arD arB cliC cliA
|
||||||
|
JVM 2: cliC cliA arD arB
|
||||||
|
JVM 3: cliA arB arD cliC
|
||||||
|
JVM 4: arD arB cliC cliA
|
||||||
|
JVM 5: arD arB cliC cliA
|
||||||
|
JVM 6: cliC cliA arB arD
|
||||||
|
JVM 7: cliC cliA arB arD
|
||||||
|
JVM 8: arD arB cliC cliA
|
||||||
|
|
||||||
|
distinct sequences seen: 4
|
||||||
|
arD arB cliC cliA x4
|
||||||
|
cliC cliA arD arB x1
|
||||||
|
cliA arB arD cliC x1
|
||||||
|
cliC cliA arB arD x2
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
# The same command line, seen by CommandLineRunner and ApplicationRunner
|
||||||
|
|
||||||
|
args passed to SpringApplication.run: [--name=Ankur, --verbose, --name=Second, data.csv, -x, --, --after-the-dashes]
|
||||||
|
|
||||||
|
CommandLineRunner String[] args = [--name=Ankur, --verbose, --name=Second, data.csv, -x, --, --after-the-dashes]
|
||||||
|
ApplicationRunner getSourceArgs() = [--name=Ankur, --verbose, --name=Second, data.csv, -x, --, --after-the-dashes]
|
||||||
|
ApplicationRunner getOptionNames() = [name, verbose]
|
||||||
|
ApplicationRunner getOptionValues(name) = [Ankur, Second]
|
||||||
|
ApplicationRunner getOptionValues(verbose) = []
|
||||||
|
ApplicationRunner getOptionValues(missing) = null
|
||||||
|
ApplicationRunner containsOption(verbose) = true
|
||||||
|
ApplicationRunner getNonOptionArgs() = [data.csv, -x, --after-the-dashes]
|
||||||
|
|
||||||
|
Environment property 'name' (both values, joined by a comma): Ankur,Second
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
# A runner throws: exit code, log, events, and the runners that never ran
|
||||||
|
|
||||||
|
|
||||||
|
--- three ordered runners, the second throws (non-web, in a separate JVM) ---
|
||||||
|
TRACE ApplicationStartedEvent
|
||||||
|
TRACE runner 1 ran
|
||||||
|
TRACE runner 2 is about to throw
|
||||||
|
TRACE ApplicationFailedEvent: java.lang.IllegalStateException: boom from runner 2
|
||||||
|
TRACE ContextClosedEvent (the context is shutting down)
|
||||||
|
ERROR o.s.b.SpringApplication : Application run failed
|
||||||
|
java.lang.IllegalStateException: boom from runner 2
|
||||||
|
process exit code: 1
|
||||||
|
|
||||||
|
--- a runner exception that implements ExitCodeGenerator ---
|
||||||
|
ERROR o.s.b.SpringApplication : Application run failed
|
||||||
|
com.ankurm.runners.failure.FailureApp$ExitWith42Exception: the data file is missing
|
||||||
|
process exit code: 42
|
||||||
|
|
||||||
|
--- a runner that throws a checked exception (declared with throws Exception) ---
|
||||||
|
ERROR o.s.b.SpringApplication : Application run failed
|
||||||
|
java.lang.IllegalStateException: Failed to execute CommandLineRunner
|
||||||
|
Caused by: java.io.IOException: cannot read data.csv
|
||||||
|
process exit code: 1
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
# An ApplicationReadyEvent listener throws: the app was 'ready' and is now gone
|
||||||
|
|
||||||
|
|
||||||
|
--- non-web application ---
|
||||||
|
TRACE ApplicationStartedEvent
|
||||||
|
TRACE ready listener 1 is about to throw (readiness: REFUSING_TRAFFIC)
|
||||||
|
TRACE ContextClosedEvent (the context is shutting down)
|
||||||
|
ERROR o.s.b.SpringApplication : Application run failed
|
||||||
|
java.lang.IllegalStateException: boom from the ready listener
|
||||||
|
process exit code: 1
|
||||||
|
|
||||||
|
--- web application (Tomcat is up when the listener runs) ---
|
||||||
|
TRACE ApplicationStartedEvent
|
||||||
|
TRACE GET /ping from inside the listener -> 200 pong
|
||||||
|
TRACE ready listener 1 is about to throw (readiness: REFUSING_TRAFFIC)
|
||||||
|
TRACE ContextClosedEvent (the context is shutting down)
|
||||||
|
ERROR o.s.b.SpringApplication : Application run failed
|
||||||
|
java.lang.IllegalStateException: boom from the ready listener
|
||||||
|
process exit code: 1
|
||||||
|
does the port answer after the process ended? false
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
# A runner that blocks: the port is open, liveness is UP, readiness is OUT_OF_SERVICE
|
||||||
|
|
||||||
|
|
||||||
|
--- while the runner is blocked ---
|
||||||
|
has SpringApplication.run() returned? false
|
||||||
|
GET /ping -> 200 pong
|
||||||
|
GET /actuator/health/liveness -> 200 {"status":"UP"}
|
||||||
|
GET /actuator/health/readiness -> 503 {"status":"OUT_OF_SERVICE"}
|
||||||
|
|
||||||
|
--- after the runner returned ---
|
||||||
|
has SpringApplication.run() returned? true
|
||||||
|
GET /actuator/health/readiness -> 200 {"status":"UP"}
|
||||||
|
|
||||||
|
--- trace ---
|
||||||
|
runner: started on platform thread app-main, now waiting (think: warming a cache for 40 s)
|
||||||
|
runner: finished, returning to SpringApplication.run
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
# A runner that starts a virtual thread and returns: readiness does not wait, and failures do not fail startup
|
||||||
|
|
||||||
|
|
||||||
|
--- the background work is still waiting ---
|
||||||
|
has SpringApplication.run() returned? true
|
||||||
|
GET /actuator/health/readiness -> 200 {"status":"UP"}
|
||||||
|
|
||||||
|
--- after the background work threw ---
|
||||||
|
GET /actuator/health/readiness -> 200 {"status":"UP"}
|
||||||
|
GET /ping -> 200 pong
|
||||||
|
|
||||||
|
--- trace ---
|
||||||
|
runner: started the work on a virtual thread and returned
|
||||||
|
background work on a virtual thread failed: the warm-up blew up
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
# SmartLifecycle: start in ascending phase, stop in descending phase, runners after all of it
|
||||||
|
|
||||||
|
|
||||||
|
--- startup ---
|
||||||
|
start low (phase -100) on main
|
||||||
|
start mid (phase 0) on main
|
||||||
|
start high (phase 100) on main
|
||||||
|
start slow (phase 200) on main
|
||||||
|
ApplicationStartedEvent
|
||||||
|
CommandLineRunner runs
|
||||||
|
ApplicationReadyEvent
|
||||||
|
manual.isAutoStartup()=false isRunning()=false (never started by the context)
|
||||||
|
|
||||||
|
--- context.close() ---
|
||||||
|
start manual (phase 50) on main
|
||||||
|
ContextClosedEvent (stop phase begins)
|
||||||
|
stop slow (phase 200) begins, will finish in 300 ms
|
||||||
|
stop slow finished, calling the callback
|
||||||
|
stop high (phase 100) on main
|
||||||
|
stop manual (phase 50) on main
|
||||||
|
stop mid (phase 0) on main
|
||||||
|
stop low (phase -100) on main
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
# Every SmartLifecycle bean in a servlet web application, by phase (the ones this project adds are marked)
|
||||||
|
|
||||||
|
phase -2147483647 springBootLoggingLifecycle Lifecycle
|
||||||
|
phase -100 low (this project)
|
||||||
|
phase 0 mid (this project)
|
||||||
|
phase 50 manual (this project)
|
||||||
|
phase 100 high (this project)
|
||||||
|
phase 200 slow (this project)
|
||||||
|
phase 1073741823 applicationTaskExecutor ThreadPoolTaskExecutor
|
||||||
|
phase 2147481599 webServerStartStop WebServerStartStopLifecycle
|
||||||
|
phase 2147482623 webServerGracefulShutdown WebServerGracefulShutdownLifecycle
|
||||||
|
SmartLifecycle.DEFAULT_PHASE = 2147483647
|
||||||
|
Integer.MAX_VALUE = 2147483647
|
||||||
|
timeout per shutdown phase = 30000 ms (spring.lifecycle.timeout-per-shutdown-phase, set by Boot)
|
||||||
|
Spring Boot 4.1.2, Spring Framework 7.0.10
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
# SmartLifecycle.start() throws: no ApplicationStartedEvent, no runner
|
||||||
|
|
||||||
|
start healthy (phase 0)
|
||||||
|
start broken (phase 10)
|
||||||
|
stop healthy
|
||||||
|
|
||||||
|
application started? false
|
||||||
|
org.springframework.context.ApplicationContextException: Failed to start bean 'broken'
|
||||||
|
java.lang.IllegalStateException: could not connect to the broker
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
# isRunning() always false: start() is called, stop() never is
|
||||||
|
|
||||||
|
--- startup ---
|
||||||
|
start() was called
|
||||||
|
--- after context.close() ---
|
||||||
|
(nothing)
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
# @SpringBootTest(classes = SlicesApp.class, args = "--from-the-test=1"): what ran before the first test method
|
||||||
|
|
||||||
|
CommandLineRunner from a top-level @Component ran, args = [--from-the-test=1]
|
||||||
|
CommandLineRunner nested in SlicesApp ran, args = [--from-the-test=1]
|
||||||
|
ApplicationRunner from a @Bean method ran, source args = [--from-the-test=1]
|
||||||
|
ApplicationReadyEvent listener (a top-level @Component) ran
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
# @WebMvcTest(controllers = Hello.class): what ran before the first test method
|
||||||
|
|
||||||
|
CommandLineRunner nested in SlicesApp ran, args = []
|
||||||
|
ApplicationRunner from a @Bean method ran, source args = []
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
# Spring Boot 4.1.2 sources: the order SpringApplication.run() calls things in, and callRunners
|
||||||
|
|
||||||
|
--- SpringApplication.run(): from refresh to ready (lines containing the calls) ---
|
||||||
|
18: refreshContext(context);
|
||||||
|
19: afterRefresh(context, applicationArguments);
|
||||||
|
24: listeners.started(context, timeTakenToStarted);
|
||||||
|
25: callRunners(context, applicationArguments);
|
||||||
|
28: throw handleRunFailure(context, ex, listeners);
|
||||||
|
32: listeners.ready(context, startup.ready());
|
||||||
|
36: throw handleRunFailure(context, ex, null);
|
||||||
|
|
||||||
|
--- SpringApplication.callRunners ---
|
||||||
|
private void callRunners(ConfigurableApplicationContext context, ApplicationArguments args) {
|
||||||
|
ConfigurableListableBeanFactory beanFactory = context.getBeanFactory();
|
||||||
|
String[] beanNames = beanFactory.getBeanNamesForType(Runner.class);
|
||||||
|
Map<Runner, String> instancesToBeanNames = new IdentityHashMap<>();
|
||||||
|
for (String beanName : beanNames) {
|
||||||
|
instancesToBeanNames.put(beanFactory.getBean(beanName, Runner.class), beanName);
|
||||||
|
}
|
||||||
|
Comparator<Object> comparator = getOrderComparator(beanFactory)
|
||||||
|
.withSourceProvider(new FactoryAwareOrderSourceProvider(beanFactory, instancesToBeanNames));
|
||||||
|
instancesToBeanNames.keySet().stream().sorted(comparator).forEach((runner) -> callRunner(runner, args));
|
||||||
|
}
|
||||||
|
|
||||||
|
--- SpringApplication.callRunner (what happens to a bean that is both kinds) ---
|
||||||
|
private void callRunner(Runner runner, ApplicationArguments args) {
|
||||||
|
if (runner instanceof ApplicationRunner) {
|
||||||
|
callRunner(ApplicationRunner.class, runner, (applicationRunner) -> applicationRunner.run(args));
|
||||||
|
}
|
||||||
|
if (runner instanceof CommandLineRunner) {
|
||||||
|
callRunner(CommandLineRunner.class, runner,
|
||||||
|
(commandLineRunner) -> commandLineRunner.run(args.getSourceArgs()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
--- SpringApplication.callRunner(Class, Runner, ThrowingConsumer) (where 'Failed to execute' comes from) ---
|
||||||
|
private <R extends Runner> void callRunner(Class<R> type, Runner runner, ThrowingConsumer<R> call) {
|
||||||
|
call.throwing(
|
||||||
|
(message, ex) -> new IllegalStateException("Failed to execute " + ClassUtils.getShortName(type), ex))
|
||||||
|
.accept((R) runner);
|
||||||
|
}
|
||||||
|
|
||||||
|
--- EventPublishingRunListener.started / ready ---
|
||||||
|
public void started(ConfigurableApplicationContext context, @Nullable Duration timeTaken) {
|
||||||
|
context.publishEvent(new ApplicationStartedEvent(this.application, this.args, context, timeTaken));
|
||||||
|
AvailabilityChangeEvent.publish(context, LivenessState.CORRECT);
|
||||||
|
}
|
||||||
|
public void ready(ConfigurableApplicationContext context, @Nullable Duration timeTaken) {
|
||||||
|
context.publishEvent(new ApplicationReadyEvent(this.application, this.args, context, timeTaken));
|
||||||
|
AvailabilityChangeEvent.publish(context, ReadinessState.ACCEPTING_TRAFFIC);
|
||||||
|
}
|
||||||
|
|
||||||
|
--- the marker interface that both runners extend ---
|
||||||
|
interface Runner {
|
||||||
|
public interface CommandLineRunner extends Runner {
|
||||||
|
public interface ApplicationRunner extends Runner {
|
||||||
|
|
||||||
|
--- the same method in Spring Boot 3.0.13 ---
|
||||||
|
private void callRunners(ApplicationContext context, ApplicationArguments args) {
|
||||||
|
context.getBeanProvider(Runner.class).orderedStream().forEach((runner) -> {
|
||||||
|
if (runner instanceof ApplicationRunner applicationRunner) {
|
||||||
|
callRunner(applicationRunner, args);
|
||||||
|
}
|
||||||
|
if (runner instanceof CommandLineRunner commandLineRunner) {
|
||||||
|
callRunner(commandLineRunner, args);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
--- the same method in Spring Boot 3.1.12 ---
|
||||||
|
private void callRunners(ConfigurableApplicationContext context, ApplicationArguments args) {
|
||||||
|
ConfigurableListableBeanFactory beanFactory = context.getBeanFactory();
|
||||||
|
String[] beanNames = beanFactory.getBeanNamesForType(Runner.class);
|
||||||
|
Map<Runner, String> instancesToBeanNames = new IdentityHashMap<>();
|
||||||
|
for (String beanName : beanNames) {
|
||||||
|
instancesToBeanNames.put(beanFactory.getBean(beanName, Runner.class), beanName);
|
||||||
|
}
|
||||||
|
Comparator<Object> comparator = getOrderComparator(beanFactory)
|
||||||
|
.withSourceProvider(new FactoryAwareOrderSourceProvider(beanFactory, instancesToBeanNames));
|
||||||
|
instancesToBeanNames.keySet().stream().sorted(comparator).forEach((runner) -> callRunner(runner, args));
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user