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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -137,7 +137,7 @@ private static void resolveAutoBackfill(
srcType +
", target=" +
tgtType +
". Add an explicit MergeStep.from(...) row with a converter, or rename to break the name match if this row was unintentional."
". Pre-convert the value into a matching-typed holder record, or rename to break the name match if this row was unintentional."
);
claimedTgt.add(name);
final Getter<Object, Object> reader = src -> sourceRefl.read(src, name);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -363,19 +363,20 @@ public B forward(final A a) {
* repository.save(managed);
* }</pre>
*
* <p>Semantically equivalent to {@code forward(source)} writes — every property the forward
* mapping would set is set on {@code target} via its public {@code setX(value)} setter. The hook
* chain (before/after) runs as it does in {@link #forward}; the only difference is the final
* write step targets {@code target} rather than a fresh allocation.
* <p>Semantically equivalent to {@code forward(source)} writes — each property the forward
* mapping would set is written onto {@code target} through its public {@code setX(value)} setter
* (properties without one are skipped; see the setter note below). The hook chain (before/after)
* runs as it does in {@link #forward}; the only difference is the final write step targets {@code
* target} rather than a fresh allocation.
*
* <p><b>Records rejected.</b> Records are immutable; calling {@code into(...)} on a record-target
* mapper throws {@link UnsupportedOperationException} at apply time. Use {@link #forward(Object)}
* and discard / replace the receiver, or have the target type be a bean with setters.
*
* <p><b>Setter requirement.</b> Every property emitted by the mapping must have a public {@code
* setX(...)} setter on {@code target.getClass()}. A missing setter throws {@link
* IllegalArgumentException} naming the property; this is intentional — silently skipping
* properties without setters would hide the mapping's intent.
* <p><b>Setter requirement.</b> Properties are written through public {@code setX(...)} setters
* on {@code target.getClass()}; a property without a public setter is silently skipped — the same
* semantics MapStruct applies to {@code @MappingTarget} update methods, so read-only fields on
* the target don't block the rest of the update.
Comment thread
eschizoid marked this conversation as resolved.
*
* <p><b>Return value for chaining.</b> Returns {@code target} (the same reference passed in) so
* call sites can fluently chain ({@code repository.save(mapper.into(managed, dto))}).
Expand All @@ -385,8 +386,6 @@ public B forward(final A a) {
* @return {@code target} (same reference, mutated in place)
* @throws NullPointerException if {@code target} or {@code source} is null
* @throws UnsupportedOperationException if {@code target}'s class is a record
* @throws IllegalArgumentException if any mapped property lacks a public setter on {@code
* target.getClass()}
*/
public B into(final B target, final A source) {
if (target == null) throw new NullPointerException("target");
Expand All @@ -409,9 +408,9 @@ public B into(final B target, final A source) {
// Scoping: iterate the mapper's patch-table keyset — the set of top-level target fields the
// engine actually produced values for — NOT every getter-derived property on target.getClass().
// Iterating all properties would (1) clobber unmapped pre-existing fields on the managed
// entity, defeating the "load-mutate-save" idiom; and (2) try to setX(...) on read-only
// computed getters (e.g. getFullName() derived from firstName + lastName), raising an IAE for
// a property the user never asked us to map.
// entity, defeating the "load-mutate-save" idiom; and (2) stage pointless reads for read-only
// computed getters (e.g. getFullName() derived from firstName + lastName) whose writes would
// silently no-op — work for a property the user never asked us to map.
final var staged = new LinkedHashMap<String, Object>(patchByTargetField.size());
for (final var name : patchByTargetField.keySet()) {
staged.put(name, targetRefl.read(produced, name));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,9 @@
* target component by name.
*
* <p>Single arity-agnostic type. The source slot is recovered from the source accessor's declaring
* class via {@code SerializedLambda} — the same mechanism that drives {@code Mapping.auto()} — so
* the row factories scale to any number of source classes without per-arity specialization.
* class via {@code SerializedLambda} — the same class-inference mechanism the {@code Mapping} row
* factories use — so the row factories scale to any number of source classes without per-arity
* specialization.
*
* <p>Sealed over two package-private records; users construct rows through the static factories
* below.
Expand Down Expand Up @@ -41,9 +42,9 @@ record FromInferred<T, X>(Accessor<?, X> src, Accessor<T, X> tgt) implements Mer
record AutoSameName<T>(Class<?> sourceClass) implements MergeStep<T> {}

/**
* Row whose source class is inferred from the source accessor's declaring class. At build time
* the engine validates the inferred class is present in the merge's source bag declaration; at
* forward time it reads the source via {@link Sources#byClass(Class)}.
* Row whose source class is inferred from the source accessor's declaring class at build time; at
* forward time the engine reads the source via {@link Sources#byClass(Class)} — a missing entry
* throws {@link IllegalStateException} naming the class.
*
* <pre>{@code
* Telescope.merge(Profile.class,
Expand Down
107 changes: 107 additions & 0 deletions docs/mapstruct-migration.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
# Migrating from MapStruct to telescope

A working recipe for moving an existing MapStruct codebase to telescope **one mapper at a time** — no big-bang rewrite.
For the feature-by-feature reference (every MapStruct capability, the telescope idiom, and the honest limitations), see
the [parity matrix](mapstruct-parity.md). For the argument about _why_, run the head-to-head:
`./gradlew :examples:mapstruct-vs-telescope:test`.

## The shape of the change

A MapStruct mapper is an annotated interface whose implementation is generated at compile time. A telescope mapper is a
**value** you build and hold:

```java
// MapStruct — an annotated interface, implementation generated at compile time
@Mapper
public interface CustomerMapper {
@Mapping(source = "email", target = "contactEmail")
CustomerDto toDto(Customer customer);

Customer fromDto(CustomerDto dto); // the reverse direction is a second method
}
```

```java
// telescope — one value, both directions
import static io.github.eschizoid.telescope.mapping.Mapping.to;

Mapper<Customer, CustomerDto> MAPPER = Telescope.mapper(
Customer.class,
CustomerDto.class,
to(Customer::email, CustomerDto::getContactEmail)
);
// MAPPER.forward(customer) and MAPPER.backward(dto) — no second method
```

Strings become typed method references (the compiler checks them, the IDE refactors them), the reverse direction is
free, and the mapper can [explain and trace itself](../README.md) — which is also how you _verify_ each migration step
below.

## Coexistence: migrate one mapper at a time

MapStruct and telescope run side by side indefinitely — they don't know about each other:

1. Keep `mapstruct` + its processor on the build; add `telescope-core`.
2. Pick one MapStruct mapper. Translate it (table below). Expose the telescope `Mapper<A, B>` wherever the old bean was
injected — in Spring / Quarkus, declare it as a `@Bean` / `@Produces` and the starter's `TelescopeMapperRegistry`
indexes it by `(sourceClass, targetClass)` automatically.
3. Delete the MapStruct interface once its call sites are moved. Repeat.

Nothing forces order: leave the long tail on MapStruct as long as you like.

## Translation table

| MapStruct | telescope |
| ------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| implicit same-name mapping | implicit too — `Telescope.mapper(A.class, B.class)` with zero rows deep-recurses by name |
| `@Mapping(source = "a", target = "b")` | `to(A::a, B::getB)` |
| `@Mapping(source = "x.y.z", target = "flat")` | `to(Telescope.of(A.class).field(A::x).field(X::y).field(Y::z), B::getFlat)` |
| `@Mapping(target = "t", ignore = true)` | `drop(A::t)` for a source-side field; a target-only field takes `constant(B::getT, …)` or the lenient `mapperForward(...)` |
| `@Mapping(expression = "java(...)")` | `to(A::src, B::getT, a -> compute(a), b -> invert(b))` — a typed function, not a string of Java |
| `@Mapping(constant = "fixed")` / `defaultValue` | `constant(B::getT, "fixed")` / `toOrElse(A::x, B::getX, fallback)` |
| type conversion (source `String` → target `LocalDate`) | `to(A::birthDate, B::getBirthDate, LocalDate::parse, LocalDate::toString)` — forward parses, backward formats |
| nested type reuse / `@Mapper(uses = …)` | `via(A::address, B::getAddress, addressMapper)` |
| `@AfterMapping` / `@BeforeMapping` | `mapper.afterForward((src, dst) -> …)` / `mapper.beforeForward(…)` — returns a new immutable mapper |
| `void update(@MappingTarget E e, D d)` | `mapper.into(entity, dto)` (silent-skip on missing setters, same semantics) / `mapper.patch(…)` |
| `CustomerDto toDto(Customer c, Address a)` | `Telescope.merge(CustomerDto.class, auto(Customer.class), from(Address::city, CustomerDto::getCity))` |
| `unmappedTargetPolicy = ERROR` | free — strict `mapper(...)` refuses unmapped fields at construction (and at **compile time** with `telescope-codegen` on the processor path) |
| `unmappedTargetPolicy = WARN` (the default) | `Telescope.mapperForward(A.class, B.class)` — lenient one-way like MapStruct's default at runtime (silently, i.e. IGNORE; for a build-time warning instead, keep strict `mapper(...)` and set `-Atelescope.verify=warn`) |
| `componentModel = "spring"/"cdi"` | plain `@Bean` / `@Produces Mapper<A, B>` + the starter's `TelescopeMapperRegistry` |
| generated `…MapperImpl` you read to debug | `mapper.explain()` / `mapper.trace(input)` / flip a log level — [introspection](../README.md) |

Every mapping row above is expanded — with evidence and limitations — in the [parity matrix](mapstruct-parity.md).

## Verify each step (don't trust the diff, assert it)

telescope's introspection makes each migrated mapper self-checking:

```java
import io.github.eschizoid.telescope.introspection.OpticNode.Mapped;

// the migrated mapper maps everything the old one did — nothing silently dropped
assertThat(mapper.explain().skipped()).isEmpty();

// the rename you carried over is real, enumerable data
assertThat(mapper.explain().mapped()).contains(new Mapped("email", "contactEmail"));
Comment thread
eschizoid marked this conversation as resolved.

// and for one golden input, old and new agree
assertThat(mapper.forward(sample)).isEqualTo(oldMapStructMapper.toDto(sample));
```

That last equality assertion — run once per migrated mapper against the still-present MapStruct implementation — is the
cheapest possible safety net, and you delete it together with the old interface.

## Gotchas worth knowing up front

- **Strict vs lenient is a choice, not a default fight.** `Telescope.mapper(...)` is strict (bidirectional, refuses
unmapped fields); `Telescope.mapperForward(...)` is lenient one-way and matches MapStruct's default. Migrating a
mapper often _surfaces_ fields MapStruct was silently nulling — that's the point, not a regression.
- **Multiple source parameters** go through `Telescope.merge(...)` with a `Sources` bag — the source-class check happens
at `forward(...)` time, not at the method signature (see the matrix row for details).
- **Row-group reuse** (`MapperBuilder.inherit`) currently applies only across mappers of the **same** (source, target)
pair — a group inherited into a different pair is silently dropped
([#223](https://github.com/eschizoid/telescope/issues/223)).
- **Multi-constructor immutables** (MapStruct's `@Default`) aren't disambiguated — records and single-constructor
classes are the supported shapes.
- **Hot loops:** the runtime path is reflective (fast enough outside hot paths); annotate the pair with
`@Focus`/`@Bridge` and the codegen path is reflection-free.
Loading
Loading