From bd75e9566c6763bf37b88b7754985e9f138c1ec8 Mon Sep 17 00:00:00 2001 From: mariano Date: Wed, 8 Jul 2026 23:16:00 -0500 Subject: [PATCH 1/6] docs: MapStruct parity matrix + migration guide (audit deliverables) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ships the two adoption documents produced by the evidence-grounded parity audit (7 feature-group auditors, each adversarially re-verified against the cited source and tests): - docs/mapstruct-parity.md — 29 MapStruct features scored 13 full / 16 partial / 0 missing, each with the concrete telescope idiom, honest limitations, and the file:line evidence the verdict rests on. Two verdicts were downgraded and two snippets corrected by the verification pass. - docs/mapstruct-migration.md — the one-mapper-at-a-time migration recipe: coexistence setup, a MapStruct->telescope translation table, introspection- based verification asserts, and the up-front gotchas. Audit follow-ups fixed in the same pass: - Mapper.into javadoc claimed a missing setter throws IllegalArgumentException; the implementation (and MapperIntoTest) silently skips it, matching MapStruct's @MappingTarget semantics — javadoc now says so. - MergeStep javadoc referenced the removed Mapping.auto() factory; reworded to the class-inference mechanism the Mapping row factories actually share. --- .../telescope/conversion/Mapper.java | 10 +- .../telescope/mapping/MergeStep.java | 5 +- docs/mapstruct-migration.md | 93 ++ docs/mapstruct-parity.md | 1064 +++++++++++++++++ 4 files changed, 1164 insertions(+), 8 deletions(-) create mode 100644 docs/mapstruct-migration.md create mode 100644 docs/mapstruct-parity.md diff --git a/core/src/main/java/io/github/eschizoid/telescope/conversion/Mapper.java b/core/src/main/java/io/github/eschizoid/telescope/conversion/Mapper.java index 46ab492c..745b1325 100644 --- a/core/src/main/java/io/github/eschizoid/telescope/conversion/Mapper.java +++ b/core/src/main/java/io/github/eschizoid/telescope/conversion/Mapper.java @@ -372,10 +372,10 @@ public B forward(final A a) { * 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. * - *

Setter requirement. 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. + *

Setter requirement. 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. * *

Return value for chaining. Returns {@code target} (the same reference passed in) so * call sites can fluently chain ({@code repository.save(mapper.into(managed, dto))}). @@ -385,8 +385,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"); diff --git a/core/src/main/java/io/github/eschizoid/telescope/mapping/MergeStep.java b/core/src/main/java/io/github/eschizoid/telescope/mapping/MergeStep.java index 1272cdb0..f656cba9 100644 --- a/core/src/main/java/io/github/eschizoid/telescope/mapping/MergeStep.java +++ b/core/src/main/java/io/github/eschizoid/telescope/mapping/MergeStep.java @@ -10,8 +10,9 @@ * target component by name. * *

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. * *

Sealed over two package-private records; users construct rows through the static factories * below. diff --git a/docs/mapstruct-migration.md b/docs/mapstruct-migration.md new file mode 100644 index 00000000..402ce945 --- /dev/null +++ b/docs/mapstruct-migration.md @@ -0,0 +1,93 @@ +# 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 // telescope +@Mapper import static io.github.eschizoid.telescope.mapping.Mapping.to; +public interface CustomerMapper { + @Mapping(source = "email", Mapper MAPPER = Telescope.mapper( + target = "contactEmail") Customer.class, CustomerDto.class, + CustomerDto toDto(Customer customer); to(Customer::email, CustomerDto::getContactEmail)); + Customer fromDto(CustomerDto dto); // backward() is the same value — 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](mapstruct-parity.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` 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)` | +| `@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` | a transform row whose function returns the constant / falls back on null | +| type conversion (`String` ↔ `LocalDate`, …) | `to(A::birthDate, B::getBirthDate, LocalDate::parse, LocalDate::toString)` | +| 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::city))` | +| `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, matching MapStruct's default posture | +| `componentModel = "spring"/"cdi"` | plain `@Bean` / `@Produces Mapper` + the starter's `TelescopeMapperRegistry` | +| generated `…MapperImpl` you read to debug | `mapper.explain()` / `mapper.trace(input)` / flip a log level — [introspection](mapstruct-parity.md) | + +Every 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 +// 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")); + +// 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. diff --git a/docs/mapstruct-parity.md b/docs/mapstruct-parity.md new file mode 100644 index 00000000..da5b3a72 --- /dev/null +++ b/docs/mapstruct-parity.md @@ -0,0 +1,1064 @@ +# telescope ↔ MapStruct — feature parity matrix + +An evidence-grounded audit of MapStruct's feature surface against telescope. Every row was produced by reading +telescope's actual source and tests (citations below), then adversarially re-verified by a second pass that re-opened +every citation — two verdicts were downgraded and two snippets corrected in that pass. Audited against MapStruct 1.6.3 +semantics and telescope `main`. + +**Legend:** ✅ full — the use case is covered, possibly via a different idiom that is no worse · ⚠️ partial — +achievable, with the real limitation stated in its notes · ❌ missing. + +## Summary + +| Area | Feature | Status | +| ------------------------- | -------------------------------------------------------------------------------------------------------------------------------- | :----: | +| Core mapping | [Implicit same-name mapping](#implicit-same-name-mapping) | ✅ | +| Core mapping | [Explicit rename](#explicit-rename) | ✅ | +| Core mapping | [Nested source path](#nested-source-path) | ✅ | +| Core mapping | [Multiple source parameters](#multiple-source-parameters) | ⚠️ | +| Conversions | [Implicit type conversions](#implicit-type-conversions) | ⚠️ | +| Conversions | [Format strings](#format-strings) | ⚠️ | +| Conversions | [Expressions](#expressions) | ✅ | +| Conversions | [Constants and defaults](#constants-and-defaults) | ✅ | +| Collections & containers | [Collection element mapping](#collection-element-mapping-listentity---listdto-reusing-the-element-mapping) | ✅ | +| Collections & containers | [Map mapping](#map-mapping-mapkv-valuekey-mapping-mapmappingvaluedateformat) | ⚠️ | +| Collections & containers | [Collection target strategies](#collection-target-strategies-collectionmappingstrategy-mappingtarget-collection-merge-semantics) | ⚠️ | +| Collections & containers | [Stream support](#stream-support-streama---listb--stream-mappings) | ⚠️ | +| Lifecycle & customization | [Before/after hooks](#beforeafter-hooks-beforemapping--aftermapping) | ✅ | +| Lifecycle & customization | [Context parameters](#context-parameters-context) | ⚠️ | +| Lifecycle & customization | [Qualifiers](#qualifiers-named--custom-qualifier) | ✅ | +| Lifecycle & customization | [Mapper composition](#mapper-composition-mapperuses) | ✅ | +| Object creation & update | [In-place update methods](#in-place-update-methods-mappingtarget) | ✅ | +| Object creation & update | [Object factories](#object-factories-objectfactory) | ⚠️ | +| Object creation & update | [Builder support](#builder-support-auto-detected-builders) | ⚠️ | +| Object creation & update | [Records and constructor mapping](#records-and-constructor-mapping) | ⚠️ | +| Policies & null handling | [Unmapped-target policy](#unmapped-target-policy) | ✅ | +| Policies & null handling | [Null value strategies](#null-value-strategies) | ⚠️ | +| Policies & null handling | [Null check strategy](#null-check-strategy) | ⚠️ | +| Policies & null handling | [Conditional mapping](#conditional-mapping) | ⚠️ | +| Advanced | [Config inheritance](#config-inheritance-inheritconfiguration--inheritinverseconfiguration) | ⚠️ | +| Advanced | [Decorators](#decorators-decoratedwith) | ✅ | +| Advanced | [DI component models](#di-component-models-componentmodel--springcdijakarta) | ✅ | +| Advanced | [Enum mapping](#enum-mapping-valuemapping--enummapping) | ⚠️ | +| Advanced | [Subclass mapping](#subclass-mapping-subclassmapping) | ⚠️ | + +**Tally: 13 ✅ · 16 ⚠️ · 0 ❌** — no MapStruct feature is unreachable; every ⚠️ has a working idiom with its limitation +stated. + +## Core mapping + +### ✅ Implicit same-name mapping + +**MapStruct:** Unannotated properties map by name automatically + +**telescope:** + +```java +// No rows = pure deep auto-recursion: same-name components identity-map, +// nested records/POJOs recurse, List/Set/Map/Optional lift automatically +Mapper mapper = Telescope.mapper(UserEntity.class, UserDto.class); + +UserDto dto = mapper.forward(entity); + +// MapStruct-style lenient one-way (unmatched target fields take JLS defaults): +ForwardMapper projector = Telescope.mapperForward(UserEntity.class, UserDto.class); +``` + +Matching is exact name + type (ADR-0002 explicitly rejects fuzzy heuristics — same posture as MapStruct). One semantic +difference: bidirectional Telescope.mapper() is strict (unmatched fields need drop()/rows) because it guards backward(); +the MapStruct-equivalent lenient behavior is Telescope.mapperForward(), which silently ignores unmatched source fields +and JLS-defaults unmatched targets. Deep recursion is arguably stronger than MapStruct's: containers lift automatically +at any depth and cycles terminate. + +Evidence: core/src/main/java/io/github/eschizoid/telescope/Telescope.java:497-563 (deep recursion + 'Same-name +1-liner' javadoc at 519-523), 573-576 (mapper), 660-666+682-717 (mapperForward lenient-by-default, 'matches MapStruct's +generated-mapper default'); core/src/test/java/io/github/eschizoid/telescope/DeepMappingTest.java:94-116 ('Pure +same-name deep copy — no overrides needed': Telescope.map(SameAddrEntity.class, SameAddrDto.class) with zero rows); +docs/adr/0002-no-fuzzy-auto-mapping.md + +### ✅ Explicit rename + +**MapStruct:** @Mapping(source = "email", target = "contactEmail") + +**telescope:** + +```java +import static io.github.eschizoid.telescope.mapping.Mapping.to; + +Mapper mapper = Telescope.mapper( + Customer.class, + CustomerDto.class, + to(Customer::email, CustomerDto::contactEmail) +); // typed method refs, no strings +``` + +Strictly better than MapStruct's string attributes: source/target are compile-checked Serializable method references, so +IDE rename refactors follow and typos fail at javac, not at annotation-processing. One rename row is keyed by +(sourceClass, targetClass) and applies at every depth where the pair recurses — MapStruct needs a @Mapping per mapper +method. + +Evidence: core/src/main/java/io/github/eschizoid/telescope/mapping/Mapping.java:97-100 (to(src, tgt) same-typed +rename) and 111-118 (to(src, tgt, fwd, bwd) typed transform for renames that also change type); +core/src/test/java/io/github/eschizoid/telescope/DeepMappingTest.java:122-128 (to(CompanyEntity::founded, +CompanyDto::since) rename in 5-level nesting); core/src/main/java/io/github/eschizoid/telescope/Telescope.java:509-517 +(javadoc: a rename row applies wherever recursion lands on that type pair) + +### ✅ Nested source path + +**MapStruct:** @Mapping(source = "customer.address.city", target = "city") — dotted path into nested source + +**telescope:** + +```java +import static io.github.eschizoid.telescope.mapping.Mapping.to; + +// Nested source -> flat target: source side is a real Telescope path, each hop typed +Mapper mapper = Telescope.mapper( + Order.class, OrderDto.class, + to(Telescope.of(Order.class).field(Order::customer).field(Customer::address).field(Address::city), + OrderDto::city)); + +// Or with a @Focus-generated navigator (zero runtime reflection on the path): +to(OrderTelescope.of().customer().address().city(), OrderDto::city) +``` + +All three shapes covered: nested source -> flat target, flat source -> nested target (with automatic intermediate +allocation for record intermediates, Mapping.java:349-355), and nested -> nested. Paths are typed Telescope values +instead of dotted strings, so javac checks every hop. Rows using Telescope paths apply at the outer (source, target) +pair only — same scope as MapStruct's per-method @Mapping. Known edge: flat-source -> nested-target intermediate +allocation needs a via(...) workaround for bean intermediates lacking a no-arg ctor/builder. + +Evidence: core/src/main/java/io/github/eschizoid/telescope/mapping/Mapping.java:361-379 (to(Telescope, +Accessor) — javadoc: 'Closes MapStruct's @Mapping(source = "a.b.c", target = "flat")'), 381-402 (both-nested +to(Telescope, Telescope)), 320-359 (nested-target mirror); +core/src/test/java/io/github/eschizoid/telescope/TelescopeMappingTest.java:181-193 (forward reads nested source path, +writes flat target), 200-214 (both-nested), 158-174 (codegen navigators on both sides) + +### ⚠️ Multiple source parameters + +**MapStruct:** CustomerDto toDto(Customer c, Address a) — one target built from several sources + +**telescope:** + +```java +import static io.github.eschizoid.telescope.mapping.MergeStep.auto; +import static io.github.eschizoid.telescope.mapping.MergeStep.from; + +Mapper mapper = Telescope.merge( + Profile.class, + auto(Customer.class), // backfill same-name fields from Customer + from(Audit::createdBy, Profile::createdBy), // source slot inferred from declaring class + from(Audit::createdAt, Profile::createdAt) +); + +Profile p = mapper.forward(Sources.of(customer, audit)); // any arity, no per-arity overloads +``` + +The use case is covered by Telescope.merge + a Sources bag, including per-source auto() same-name backfill and arbitrary +arity. Genuine limitations vs MapStruct's typed method signature: (1) the call boundary is +mapper.forward(Sources.of(...)) — a missing/wrong source class is caught at forward time (IllegalStateException), not at +compile time like toDto(Customer, Address); (2) each source must have a distinct runtime class — two same-typed sources +(MapStruct disambiguates by parameter name) require pre-aggregating into a holder record; (3) forward-only: +backward()/patch() throw (MapStruct multi-source is also inherently one-way, so this is minor). + +Evidence: core/src/main/java/io/github/eschizoid/telescope/Telescope.java:803-859 (merge javadoc + factory, incl. +'Distinct runtime classes' at 832-835 and 'Backward is unsupported' at 836-840); +core/src/main/java/io/github/eschizoid/telescope/mapping/MergeStep.java:26-81 (from(src, tgt) class-inferred rows, +auto(Class) same-name backfill); core/src/test/java/io/github/eschizoid/telescope/MergeTest.java:52-60 (2-source +forward), 97-104 (arity 3), 121-130 (arity 5, single factory) + +## Conversions + +### ⚠️ Implicit type conversions + +**MapStruct:** Automatic conversions between source/target types: primitive<->wrapper autoboxing, String<->number, +enum<->String, date/time<->String, widening numeric conversions — all silent, no declaration needed. + +**telescope:** + +```java +// Auto (no row needed): primitive<->wrapper, collection copies, Optional<->nullable +final var mapper = Telescope.mapper(Source.class, Target.class); // boolean<->Boolean, Integer<->int just work, null-safe + +// Everything else is an explicit typed row (by design — ADR-0002): +Telescope.mapper(Src.class, Dst.class, + to(Src::getAttempts, Dst::getAttemptsText, + i -> i == null ? null : String.valueOf(i), + s -> s == null ? null : Integer.parseInt(s)), // String<->number + to(Src::status, Dst::statusName, Enum::name, s -> Status.valueOf(s)), // enum<->String + enumTo(Src::status, Dst::status, SrcStatus.class, DstStatus.class)); // enum<->enum by name, exhaustiveness-checked +``` + +Primitive<->wrapper is genuinely automatic and matches MapStruct's null->0/false behavior (tested). But String<->number, +enum<->String, and date/time conversions are deliberately NOT implicit (ADR-0002 'no fuzzy auto-mapping'): a same-name +field with mismatched types throws at mapper-build time with a message naming the fix. The workaround is one explicit +to(src, tgt, fwd, bwd) row per pair — compile-typed, but a MapStruct migrator must write rows MapStruct generated +silently. enum<->enum by name gets first-class sugar (enumTo, with build-time exhaustiveness MapStruct lacks); +enum<->String does not. + +Evidence: core/src/main/java/io/github/eschizoid/telescope/DeepMap.java:1016-1035 (Identity / PrimitiveWrapper +autobox with JLS-default null guard / CollectionCopy branches), DeepMap.java:1049-1069 (Optional<->nullable lift); +core/src/test/java/io/github/eschizoid/telescope/MigrationRegressionTest.java:472-500 (primitive<->wrapper auto-boxed, +null boxed -> JLS default), MigrationRegressionTest.java:2815-2845 (same-name Integer vs String pair fails fast pointing +at 'to(src, tgt, forward, backward)'; 4-arg transform is the documented fix); +core/src/main/java/io/github/eschizoid/telescope/mapping/Mapping.java:175-188 (enumTo); +docs/adr/0002-no-fuzzy-auto-mapping.md (exact name+type only, explicit rows otherwise) + +### ⚠️ Format strings + +**MapStruct:** @Mapping(numberFormat = "$#.00") / @Mapping(dateFormat = "dd.MM.yyyy") — annotation attribute, MapStruct +generates DecimalFormat/SimpleDateFormat code for both directions. + +**telescope:** + +```java +private static final DateTimeFormatter D = DateTimeFormatter.ofPattern("dd.MM.yyyy"); + +Telescope.mapper(Order.class, OrderDto.class, + to(Order::shipDate, OrderDto::shipDateText, D::format, s -> LocalDate.parse(s, D)), // bidirectional + toOneWay(Order::createdAt, OrderDto::createdAtIso, Instant::toString)); // forward-only + +// Codegen (@Bridge) form — static helper via @Transform(method = ...): +@Bridge(value = UserDto.class, transforms = { + @Transform(field = "expiresAt", using = DateHelpers.class, method = "expiry") }) +public record UserEntity(String id, Instant expiresAt) {} +``` + +No format-string attribute exists anywhere — no numberFormat/dateFormat analog, and a repo-wide grep finds no +DecimalFormat/DateTimeFormatter machinery in main code. The use case is fully achievable with a typed transform row +holding your own formatter (compile-checked, refactor-safe, arguably safer than a stringly pattern), but the migrator +hand-writes the formatter and, for bidirectional number formats, the parse leg including checked-exception handling that +MapStruct generates. Codegen path needs a whole @Transform + helper class per formatted field — more ceremony than one +annotation attribute. + +Evidence: core/src/main/java/io/github/eschizoid/telescope/mapping/Mapping.java:111-148 (4-arg to typed transform +with Instant::toString/Instant::parse example; toOneWay forward-only); +core/src/main/java/io/github/eschizoid/telescope/annotations/Transform.java:50-100 (per-field BridgeFn / static-method +qualifier dispatch, DateTimeFormatter example at lines 83-94); README.md:981-991 (parity table maps to(src,tgt,fwd,bwd) +to @Mapping qualifiedBy); core/src/test/java/io/github/eschizoid/telescope/MigrationRegressionTest.java:2826-2845 +(number<->String transform round-trip) + +### ✅ Expressions + +**MapStruct:** @Mapping(expression = "java(...)") for computed target values and defaultExpression = "java(...)" for +lazily-computed null fallbacks; string-templated Java inside an annotation. + +**telescope:** + +```java +Telescope.mapper(Order.class, OrderDto.class, + to(Order::id, OrderDto::id), + compute(OrderDto::createdAt, Instant::now), // expression: fresh per forward call + compute(OrderDto::traceId, UUID::randomUUID), + compute(OrderDtoTelescope.of().audit().createdAt(), Instant::now), // nested target + toOrElseGet(Order::createdAt, OrderDto::createdAt, Instant::now)) // defaultExpression + // source-referencing expression (MapStruct: expression = "java(s.first() + \" \" + s.last())"): + .afterForward((src, dto) -> dto.withDisplayName(src.firstName() + " " + src.lastName())); +// or per-field: toOneWay(Order::total, OrderDto::totalText, t -> "$" + t) +``` + +Strictly better idiom than MapStruct's stringly expression: plain typed Java, javac-checked, refactor-safe, no +expression parser. compute() takes a Supplier (no source arg) — source-dependent expressions route through toOneWay(src, +tgt, fn) for one source field or the typed afterForward((src, dto) -> ...) hook for multi-field derivation. +compute/constant rows are forward-only by design (backward drops the slot). Codegen @Compute requires a top-level +Supplier class (no lambdas in annotations) — slightly heavier than runtime form. + +Evidence: core/src/main/java/io/github/eschizoid/telescope/mapping/Mapping.java:527-553 (compute(tgt, Supplier), +javadoc names the expression=java(Instant.now()) gap), Mapping.java:582-597 (nested-target compute), +Mapping.java:276-318 (toOrElseGet closes defaultExpression, plus predicate-gated overload), Mapping.java:142-148 +(toOneWay per-field function); core/src/main/java/io/github/eschizoid/telescope/conversion/Mapper.java:497-522 +(afterForward(BiFunction) source-aware hook, typed @AfterMapping analog); +core/src/test/java/io/github/eschizoid/telescope/MappingConstantComputeTest.java:104-148 (fresh-per-call, forward-only +semantics pinned); core/src/main/java/io/github/eschizoid/telescope/annotations/Compute.java:37-51 (codegen +@Compute(using = Supplier.class) for @Bridge); README.md:991 + +### ✅ Constants and defaults + +**MapStruct:** @Mapping(target = "x", constant = "fixed") stamps a literal; @Mapping(target = "x", defaultValue = +"fallback") substitutes when source is null. + +**telescope:** + +```java +Telescope.mapper(Order.class, OrderDto.class, + to(Order::id, OrderDto::id), + constant(OrderDto::tenant, "production"), // @Mapping(constant = ...), typed literal + constant(OrderDto::apiVersion, 7), + constant(OrderDtoTelescope.of().shipping().country(), "US"), // nested target + toOrElse(Order::region, OrderDto::region, "EMEA"), // @Mapping(defaultValue = ...) + toOrElse(Order::displayName, OrderDto::displayName, "(unnamed)", String::isBlank), // predicate-gated + toOrElseGet(Order::traceId, OrderDto::traceId, UUID::randomUUID)); // lazy default + +// Codegen (@Bridge) form: +@Bridge(value = UserEntity.class, + constants = { @Constant(field = "source", value = "API") }, + defaults = { @Default(field = "region", value = "EMEA") }) +public record User(String id, String region) {} +``` + +Complete coverage in both runtime and codegen paths, statically typed (constant(OrderDto::apiVersion, 7) is an int, not +a string literal to parse). Exceeds MapStruct: predicate-gated toOrElse/toOrElseGet handle empty-string/empty-collection +'missing' semantics MapStruct's strictly-null defaultValue cannot, and nested-target constants (@Mapping(target="a.b.c", +constant=...)) work via Telescope paths. Known asymmetries, documented and tested: constant is forward-only (backward +drops the slot), and a substituted default round-trips backward as itself rather than the original null. Codegen +@Default is strict-null only (annotation attributes cannot hold predicates). + +Evidence: core/src/main/java/io/github/eschizoid/telescope/mapping/Mapping.java:500-525 (constant(tgt, value), +javadoc names the @Mapping constant gap), Mapping.java:555-580 (nested-target constant), Mapping.java:227-274 (toOrElse +strict-null + predicate-gated), Mapping.java:276-318 (toOrElseGet lazy + predicate-gated); +core/src/test/java/io/github/eschizoid/telescope/MappingConstantComputeTest.java:46-101 (constant stamps literal; +backward drops to type default), core/src/test/java/io/github/eschizoid/telescope/MappingOrElseTest.java:27-155 +(null->default, pass-through, lazy supplier, empty-string/empty-collection predicates); +core/src/main/java/io/github/eschizoid/telescope/annotations/Constant.java:41-50 and annotations/Default.java:48-61 +(codegen literals, parsed at emit time against field type); README.md:986-990 + +## Collections & containers + +### ✅ Collection element mapping (List -> List reusing the element mapping) + +**MapStruct:** List map(List cars) — generated loop reuses the element mapping method automatically + +**telescope:** + +```java +// auto: element pair resolved and lifted through the container, at any depth +final Mapper teamMapper = Telescope.mapper( + TeamEntity.class, + TeamDto.class, + to(UserEntity::name, UserDto::fullName) +); // rename fires on every List element + +// or reuse a pre-built element mapper explicitly: +final Mapper teamMapper2 = Telescope.mapper( + TeamEntity.class, + TeamDto.class, + via(TeamEntity::members, TeamDto::members, userMapper) +); // Mapper auto-lifts through List +``` + +Element mapping is reused automatically (same-name recursion) or explicitly (via(...) with a pre-built element Mapper), +including nested containers like List> and Map>. Bidirectional round-trip comes free. One honesty +caveat: auto-lift requires SAME-kind containers on both sides (PairingRules.java:79 — srcView.kind() == tgtView.kind()); +MapStruct's List -> Set cross-kind copy needs an explicit to(src, tgt, fwd, bwd) row in telescope. Target concrete class +(ArrayList/LinkedList/CopyOnWriteArrayList/...) is honored via listAllocatorFor (DeepMap.java:1441-1461). + +Evidence: core/src/main/java/io/github/eschizoid/telescope/DeepMap.java:65-73 (engine javadoc: +List/Set/Map-values/Optional lift the element Iso, containers nest to any depth), DeepMap.java:1093-1124 (LiftContainer +dispatch recursing on element type), DeepMap.java:1213-1268 (liftViaIfNeeded: element-level Mapper auto-lifted through +the accessor's container); core/src/main/java/io/github/eschizoid/telescope/mapping/Mapping.java:427-449 (via javadoc + +factory: 'List pair, auto-lifts'); core/src/test/java/io/github/eschizoid/telescope/DeepMappingTest.java:125-178 +(5-level nesting, element rename fires inside List of List of List), 264-289 (List> and Map> +auto-lift), 342-372 (via(...) lifts Mapper through a List accessor pair, round-trips) + +### ⚠️ Map mapping (Map value/key mapping, @MapMapping(valueDateFormat=...)) + +**MapStruct:** Map map(Map m); +@MapMapping(keyDateFormat/valueDateFormat/keyQualifiedBy/valueQualifiedBy) + +**telescope:** + +```java +// Map VALUES map automatically when the value types are a mappable pair; keys pass through verbatim +final Mapper m = Telescope.mapper(CompanyEntity.class, CompanyDto.class); +// Map -> Map, keys preserved + +// explicit value mapper for one Map field: +via(CompanyEntity::officesByRegion, CompanyDto::officesByRegion, addressMapper) + +// value formatting (@MapMapping(valueDateFormat) analog) — whole-field typed transform: +to(AuditEntity::timestamps, AuditDto::timestamps, + ts -> ts.entrySet().stream().collect(toMap(Map.Entry::getKey, e -> FMT.format(e.getValue()))), + ts -> ts.entrySet().stream().collect(toMap(Map.Entry::getKey, e -> LocalDate.parse(e.getValue(), FMT)))) +``` + +VALUE mapping is fully covered: auto-lifted through Iso.liftMapValues at any depth, or explicit via(...) with a +value-level Mapper. KEY mapping is deliberately not supported — key types must match exactly and keys are copied +verbatim; a key conversion (e.g. String keys -> enum keys) requires a manual whole-field to(src, tgt, fwd, bwd) +transform over the entire Map. No per-key/per-value format sugar like @MapMapping(valueDateFormat) — the equivalent is a +hand-written stream/collect transform row. Target Map concrete type is honored; EnumMap targets are rejected with a +precise error (needs an explicit row). + +Evidence: core/src/main/java/io/github/eschizoid/telescope/DeepMap.java:1116-1120 (MAP_VALUES lift), 1405-1434 +(liftMapIntoTargetRaw: 'Preserves source keys verbatim'), 1238-1246 (via(...) throws when Map key types differ: 'Key +types must match exactly; auto-lifting preserves the source keys'), 1487-1508 (mapAllocatorFor: +HashMap/LinkedHashMap/TreeMap/ConcurrentHashMap/...; EnumMap rejected at plan time); +internal/src/main/java/io/github/eschizoid/telescope/internal/pairing/PairingRules.java:80-91 (mismatched key types -> +Incompatible), 119-122 (non-class key type arg means the Map is not treated as liftable); +core/src/test/java/io/github/eschizoid/telescope/DeepMappingTest.java:172-174 (Map values recursed, keys preserved), +280-289 (Map> auto-lifts); +core/src/test/java/io/github/eschizoid/telescope/DeepMapCoverageTest.java:77,152 (key-type mismatch rejection +asserted) + +### ⚠️ Collection target strategies (CollectionMappingStrategy, @MappingTarget collection merge semantics) + +**MapStruct:** CollectionMappingStrategy = ACCESSOR_ONLY / SETTER_PREFERRED / ADDER_PREFERRED / TARGET_IMMUTABLE; +@MappingTarget updates an existing target's collections + +**telescope:** + +```java +// fresh-allocation path: target collection's DECLARED concrete class is honored +// (ArrayList/LinkedList/Vector/CopyOnWriteArrayList/TreeSet/ConcurrentHashMap/... + user subclasses) +final Mapper mapper = Telescope.mapper( + Order.class, OrderEntity.class, + writeBeans(SETTERS), // pin bean write strategy tree-wide + writeBean(CashRegisterEntity.class, FIELDS)); // per-class override + +// @MappingTarget analog — mutate an existing managed entity in place: +final OrderEntity managed = repository.findById(id).orElseThrow(); +mapper.into(managed, dto); // collection fields REPLACED via setItems(...), not merged +repository.save(managed); +``` + +What exists: (1) target collection concrete type preserved on fresh allocation, with plan-time errors instead of silent +ArrayList substitution; (2) Mapper.into(target, source) is the @MappingTarget analog — setter-based in-place update, +roughly ACCESSOR_ONLY/SETTER_PREFERRED semantics; (3) writeBean/writeBeans pins the bean construction strategy per +class. What's genuinely missing: ADDER_PREFERRED (no orderEntity.addLineItem(x) adder dispatch — the JPA bidirectional +back-reference wiring use case has no telescope equivalent), and true collection MERGE on an existing target (into() +replaces the collection reference via the setter; it never getItems().clear()/addAll() into the existing instance — +matters for Hibernate PersistentCollection orphan-removal). into() also requires public setters and rejects record +targets. + +Evidence: core/src/main/java/io/github/eschizoid/telescope/conversion/Mapper.java:353-423 (into(): javadoc says +'Closes MapStruct's @MappingTarget for the bean path'; setter-based, records rejected, two-phase staged writes); +core/src/test/java/io/github/eschizoid/telescope/MapperIntoTest.java:127-160 (in-place mutation preserves target +identity, repeatable); core/src/main/java/io/github/eschizoid/telescope/DeepMap.java:1344-1374 + 1441-1461 +(liftListIntoTargetRaw + listAllocatorFor: result runtime class matches declared target raw class, unknown java.base +subtypes throw at plan time), 1463-1508 (set/map allocators); +core/src/main/java/io/github/eschizoid/telescope/mapping/WriteHint.java:45-98 (writeBean/writeBeans with +BUILDER/SETTERS/FIELDS/CONSTRUCTOR — no ADDER); grep for 'adder' across core/ and codegen/ returned only WriteStrategy +ladder docs (no adder support anywhere) + +### ⚠️ Stream support (Stream -> List / Stream mappings) + +**MapStruct:** List map(Stream cars) — generated terminal collect; Stream-typed sources/targets in mapper +signatures + +**telescope:** + +```java +// no Stream-shaped mapper factory — map at the call site with the element Mapper: +final Mapper carMapper = Telescope.mapper(Car.class, CarDto.class); + +final List dtos = cars.map(carMapper::forward).toList(); // Stream -> List +``` + +No Stream support in the mapping engine: Stream is not a recognized container kind, so a Stream-typed record +component or bean property cannot be auto-mapped (the pair is rejected as incompatible shapes; workaround is a manual +to(src, tgt, fwd, bwd) row that collects/re-streams). The dominant real-world case — converting a Stream at a call site +— is a one-liner with mapper::forward that is arguably no worse than declaring a Stream method on a MapStruct interface, +which is why this is partial rather than missing. But there is zero sugar: no Stream-shaped factory, no Stream field +lift, and forward-only (no backward through a consumed Stream). + +Evidence: internal/src/main/java/io/github/eschizoid/telescope/internal/pairing/ContainerView.java:14-19 (auto-lift +container kinds are exactly LIST, SET, MAP_VALUES, OPTIONAL — no STREAM); +internal/src/main/java/io/github/eschizoid/telescope/internal/pairing/PairingRules.java:106-125 (containerViewOf +recognizes only Optional/List/Set/Map — a Stream-typed component falls through to Incompatible); grep for 'Stream<' +across core/src/main, core/src/test, and examples/ found no Stream mapping API (only an unrelated record named Stream in +README.md:805) + +## Lifecycle & customization + +### ✅ Before/after hooks (@BeforeMapping / @AfterMapping) + +**MapStruct:** @BeforeMapping / @AfterMapping callback methods invoked around the generated mapping + +**telescope:** + +```java +Mapper mapper = Telescope.mapper(Entity.class, Dto.class, to(Entity::id, Dto::id)) + .beforeForward((e) -> e.normalised()) // pre-hook on source + .afterForward((src, dto) -> dto.withDisplayName(src.firstName() + " " + src.lastName())) // source-aware post-hook + .beforeBackward((dto) -> dto.trimmedIds()) + .afterBackward((dto, entity) -> entity.withUpdatedAtMillis(dto.updatedAtMillis())); +``` + +Four symmetric hooks per direction, each returning a new immutable Mapper; BiFunction overloads give the hook both +source and structural result (MapStruct's @AfterMapping-with-source pattern). Hooks are Function not Consumer, so +they work for immutable records too — arguably richer than MapStruct, where mutable-target hooks are the norm. Hooks +survive .asTelescope() and chain left-to-right. + +Evidence: core/src/main/java/io/github/eschizoid/telescope/conversion/Mapper.java:455 (beforeForward), :493 +(afterForward Function), :509 (afterForward BiFunction — javadoc at :500 explicitly cites MapStruct), :539 +(beforeBackward), :568 and :583 (afterBackward Function/BiFunction); +core/src/test/java/io/github/eschizoid/telescope/MapperPostHooksTest.java:26-269 (all four hooks, chaining order, +direction-laziness, asTelescope() propagation); +core/src/test/java/io/github/eschizoid/telescope/MigrationRegressionTest.java:1239, 1268, 1885; README.md:372 +(parity-table row) + +### ⚠️ Context parameters (@Context) + +**MapStruct:** @Context param threaded through nested mappings without being mapped itself (e.g. Locale, cycle-tracking) + +**telescope:** + +```java +// No @Context analog — capture the context in row functions at mapper-build time: +static Mapper orderMapper(final Locale locale) { + return Telescope.mapper( + Order.class, + OrderDto.class, + to( + Order::total, + OrderDto::totalText, + (amount) -> NumberFormat.getCurrencyInstance(locale).format(amount), + (text) -> parseAmount(text, locale) + ) + ); +} + +// The cycle-tracking @Context use case needs zero code — cycles are handled automatically: +final var mapper = Telescope.mapper(Node.class, NodeDto.class); // self/mutual recursion just works +``` + +The single most common @Context use case — CycleAvoidingMappingContext — is unnecessary: DeepMap's cycle cache + +value-level seen-set handles self/mutual recursion automatically. What's genuinely missing: a per-invocation context +parameter threaded through nested via(...) mappers. forward(a) takes only the source; row functions and hooks capture +context at build time, so per-request context (Locale, tenant) means building a mapper per context value (build cost is +non-trivial) or closing over a ScopedValue/holder manually. No sugar exists for that. + +Evidence: core/src/main/java/io/github/eschizoid/telescope/conversion/Mapper.java:330 (forward(A a) — no context +overload), :426 (backward(B b)); core/src/main/java/io/github/eschizoid/telescope/DeepMap.java:1689-1719 (built-in +ThreadLocal IdentityHashMap value-level cycle guard); +core/src/test/java/io/github/eschizoid/telescope/CycleHandlingTest.java:31-86 (Optional/List self-reference and A-B +mutual recursion map without StackOverflow, zero user code); +core/src/main/java/io/github/eschizoid/telescope/mapping/Mapping.java:111 (to(src, tgt, fwd, bwd) typed-transform row +where a closure captures context) + +### ✅ Qualifiers (@Named / custom @Qualifier) + +**MapStruct:** @Named("formatter") / custom @Qualifier to pick between multiple candidate mapping methods for the same +type pair + +**telescope:** + +```java +// Runtime: mappers and converters are VALUES — name the exact one per row, no string qualifier: +Telescope.mapper(Customer.class, CustomerDto.class, + via(Customer::homeAddress, CustomerDto::homeAddress, homeAddressMapper), + via(Customer::workAddress, CustomerDto::workAddress, workAddressMapper)); +// Codegen: @Named analog — several conversion methods on one helper class, picked per field: +@Bridge(value = UserDto.class, transforms = { + @Transform(field = "expiresAt", using = DateHelpers.class, method = "expiry"), + @Transform(field = "createdAt", using = DateHelpers.class, method = "createdAt")}) +public record UserEntity(String id, Instant expiresAt, Instant createdAt) {} +``` + +The ambiguity qualifiers solve doesn't arise at runtime: each row names its converter/mapper by reference, resolved by +javac — strictly safer than @Named string matching. Codegen side has the literal @Named-equivalent via @Transform(using, +method) (forward-only, same asymmetry as a single MapStruct qualified method). One corner: the DI +TelescopeMapperRegistry allows one mapper per (src,tgt) pair — two semantically-different mappers for the same pair must +be injected directly with the framework's own @Qualifier, bypassing the registry. + +Evidence: core/src/main/java/io/github/eschizoid/telescope/annotations/Transform.java:59-100 (method() attribute — +javadoc explicitly says it "unlocks MapStruct's @Named qualifier-dispatch pattern", emits direct +UsingClass.methodName(value) static call); core/src/main/java/io/github/eschizoid/telescope/mapping/Mapping.java:111 +(to(src, tgt, fwd, bwd) — exact function per row) and :443 (via with an exact Mapper instance); +spring-boot-starter/src/main/java/io/github/eschizoid/telescope/spring/TelescopeMapperRegistry.java:44-66 (duplicate +(src,tgt) pairs throw with a message directing to Spring @Qualifier + direct injection) + +### ✅ Mapper composition (@Mapper(uses = ...)) + +**MapStruct:** @Mapper(uses = OtherMapper.class) — delegating nested type conversions to other mappers or static helper +methods + +**telescope:** + +```java +// Runtime: delegate a nested field to a pre-built Mapper; container shapes auto-lift: +Mapper addressMapper = Telescope.mapper(Address.class, AddressDto.class); + +Mapper userMapper = Telescope.mapper( + UserEntity.class, + UserDto.class, + via(UserEntity::address, UserDto::address, addressMapper), // scalar pair + via(UserEntity::teams, UserDto::teams, teamMapper) +); // List pair, auto-lifts + +// (nested record pairs also auto-recurse with NO via row — Telescope.mapper(A.class, B.class) builds sub-mappers itself) +// Codegen: point a field at an existing bridge or static helper: +@Bridge(value = OrderEntity.class, viaMappers = { @ViaMapper(field = "address", using = AddressBridge.class) }) +public record Order(String id, Address address) {} +``` + +Two mechanisms and both directions of MapStruct's uses= are covered: via(src, tgt, mapper) delegates one field to a +pre-built Mapper (with automatic List/Set/Optional/Map element-lifting — MapStruct requires an Iterable method for +that), and auto-recursion means the common uses= case (nested type pairs) needs no declaration at all. Codegen mirrors +it with @ViaMapper (delegate to a bridge class) and @Transform(using = Helper.class, method = ...) for static helper +methods. Difference in idiom: telescope binds the delegate per field explicitly, MapStruct auto-selects by type from the +uses list — telescope's form is more verbose when many fields share one nested pair but unambiguous. + +Evidence: core/src/main/java/io/github/eschizoid/telescope/mapping/Mapping.java:427-449 (via(srcAcc, tgtAcc, +mapper) factory, element-level auto-lift through List/Set/Optional/Map documented at :432-441); +core/src/main/java/io/github/eschizoid/telescope/mapping/Via.java:19 (row record); +core/src/main/java/io/github/eschizoid/telescope/annotations/ViaMapper.java:16-63 (codegen per-field bridge delegation, +static forward/backward shape); core/src/main/java/io/github/eschizoid/telescope/annotations/Bridge.java:207-222 +(viaMappers attribute); core/src/test/java/io/github/eschizoid/telescope/DeepMappingTest.java:356, 377-391 (via +precedence over auto-recursion); core/src/test/java/io/github/eschizoid/telescope/CycleHandlingTest.java:31-51 +(auto-recursion builds nested sub-mappers with no delegation rows at all) + +## Object creation & update + +### ✅ In-place update methods (@MappingTarget) + +**MapStruct:** void update(@MappingTarget Entity target, Dto source) — mutate an existing bean instead of constructing a +fresh one + +**telescope:** + +```java +final Mapper mapper = Telescope.mapper(OrderDto.class, OrderEntity.class, to(OrderDto::sku, OrderEntity::getSku)); +final OrderEntity managed = repository.findById(id).orElseThrow(); +mapper.into(managed, dto); // writes dto's mapped fields onto `managed` via setters, same reference back +repository.save(managed); +// sparse sibling: only non-null fields of a partial target overlay the base +final OrderEntity updated = mapper.patch(entity, dtoPatch); +``` + +Deliberate parity feature — the javadoc names @MappingTarget explicitly and the test class pins JPA load-mutate-save +semantics (identity preserved, only mapped fields written, atomic staging). Bean targets only: records throw +UnsupportedOperationException (MapStruct can't mutate records in place either). Properties without a public setter are +silently skipped — matching MapStruct's @MappingTarget semantics. Bonus over MapStruct: patch() gives null-skipping +sparse update without NullValuePropertyMappingStrategy.IGNORE config. + +Evidence: core/src/main/java/io/github/eschizoid/telescope/conversion/Mapper.java:353-423 (into(): javadoc says +'Closes MapStruct's @MappingTarget for the bean path', two-phase staged writes, returns same reference, records rejected +with UnsupportedOperationException at :394-400); Mapper.java:290-323 (patch() sparse overlay); +core/src/test/java/io/github/eschizoid/telescope/MapperIntoTest.java:16 ('Pins Mapper.into(target, source) — the +@MappingTarget equivalent'), :136-138 (assertSame identity preservation), :150-153 (repeatable mutation) + +### ⚠️ Object factories (@ObjectFactory) + +**MapStruct:** @ObjectFactory — a user method that instantiates the target (custom constructor args, EntityManager +lookup, DI-provided instance) which the generated mapper then populates + +**telescope:** + +```java +// 1. Pin HOW the engine constructs a target class (strategy, not arbitrary code): +Telescope.mapper(OrderRecord.class, OrderPojo.class, + writeBean(OrderPojo.class, WriteStrategy.CONSTRUCTOR), // or BUILDER / SETTERS / FIELDS + to(OrderRecord::sku, OrderPojo::getSku)); +// 2. Fabricate/load the instance yourself, then let the mapper populate it in place: +mapper.into(entityManager.find(OrderEntity.class, dto.id()), dto); +// 3. Or take over the whole conversion with hand-written functions: +Telescope.from(OrderDto.class).to(OrderEntity.class) + .using(d -> OrderEntityFactory.create(d), e -> new OrderDto(e.getSku())); +``` + +No hook to inject an arbitrary user function as the instantiator inside the deep-mapping engine — writeBean() selects +among four reflection strategies (BUILDER/SETTERS/FIELDS/CONSTRUCTOR), it cannot call your code. The two @ObjectFactory +motivations are each covered by a different idiom: 'construct differently' → writeBean strategy hint; 'populate an +instance I obtained elsewhere (JPA/DI)' → mapper.into(existing, source). A truly custom factory (e.g. constructor +needing values not on the source) forces you out to from/to/using where you hand-write the entire forward function, +losing auto-mapping for the remaining fields. + +Evidence: core/src/main/java/io/github/eschizoid/telescope/mapping/WriteHint.java:45-98 (writeBean/writeBeans +accept only the 4-value WriteStrategy enum at :58-63 — no Supplier/factory function overload); +core/src/main/java/io/github/eschizoid/telescope/conversion/MapperBuilder.java:91-160 (create/inherit/add/build — no +factory hook); core/src/main/java/io/github/eschizoid/telescope/Telescope.java:463-495 (from/to/using escape hatch); +conversion/Mapper.java:391-423 (into() covers the load-existing-instance use case); grep for 'ObjectFactory' across +_.java/_.md returned zero hits + +### ⚠️ Builder support (auto-detected builders) + +**MapStruct:** Auto-detected builder(): Immutables, Lombok @Builder, protobuf builders; customizable via BuilderProvider +SPI and @Builder(builderMethod=...) + +**telescope:** + +```java +// Auto-detected: any target with a static builder() whose builder has build() — +// probed automatically, Lombok @Builder just works: +final Mapper m = Telescope.mapper(UserDto.class, UserPojo.class, + to(UserDto::email, UserPojo::getEmail)); +// Pin it explicitly when the class also has setters (SETTERS wins by default): +Telescope.mapper(UserDto.class, UserPojo.class, + writeBean(UserPojo.class, WriteStrategy.BUILDER)); +// Codegen navigators for Lombok @Builder classes ship in telescope-lombok: +// @Builder class BuilderUser → generated BuilderUserTelescope with builder() rebuild +``` + +Covers Lombok @Builder (dedicated telescope-lombok module with integration tests) and any conventional static +builder()/build() pair, including Immutables if you map to the generated ImmutableFoo class directly (its static +builder() matches). Gaps vs MapStruct: (1) the factory method name is hard-wired to 'builder' — protobuf's newBuilder() +is NOT detected and there is no @Builder(builderMethod=...) equivalent or BuilderProvider SPI to teach it; (2) no +per-mapper toggle to prefer builder over setters other than the writeBean hint. Builder setter matching (exact / setX / +withX) covers Lombok, Immutables-fluent, and JavaBean-style builders. + +Evidence: internal/src/main/java/io/github/eschizoid/telescope/internal/Beans.java:742-768 (autoWriter probe order: +setters → static builder() at :767 → fields → all-args ctor), :720-726 (builderWriter factory), :1229-1232 + 1259-1329 +(BuilderWriter: requires static method named exactly 'builder()' returning a type with 'build()' at :1272-1289; setter +matching by exact name / setX / withX; LMF-de-reflected dispatch); mapping/WriteHint.java:50,59 (BUILDER strategy, +'requires a static builder() method'); +lombok/src/test/java/io/github/eschizoid/telescope/codegen/lombok/fixtures/BuilderUser.java:10-12 (@Builder fixture) and +LombokFocusProcessorTest.java:53-68 (@Builder and @Value+@Builder navigators verified end-to-end); +core/src/test/java/io/github/eschizoid/telescope/DeepMappingTest.java:576-712 (writeBean BUILDER/FIELDS/CONSTRUCTOR hint +tests) + +### ⚠️ Records and constructor mapping + +**MapStruct:** Constructor-based target population for records and immutable classes (parameter-name matching, @Default +constructor selection) + +**telescope:** + +```java +record UserEntity(String name, String email, AddressEntity address) {} +record UserDto(String fullName, String email, AddressDto address) {} +// records are rebuilt via the canonical constructor, nested records recursed automatically: +final Mapper m = Telescope.mapper( + UserEntity.class, UserDto.class, + to(UserEntity::name, UserDto::fullName)); // same-name fields auto-mapped +// immutable all-args-only POJO target (compile with -parameters for name matching): +Telescope.mapper(OrderRecord.class, ImmutablePojo.class, + writeBean(ImmutablePojo.class, WriteStrategy.CONSTRUCTOR)); +``` + +Records are the native happy path — canonical-constructor rebuild is the default everywhere (deep mapping, patch, @Focus +codegen), no configuration needed. Immutable non-record classes work via the CONSTRUCTOR write strategy: auto-detected +when there is exactly one public all-args constructor compiled with -parameters and parameter names align with +getter-derived properties; otherwise pin with writeBean(X.class, CONSTRUCTOR). Same -parameters dependency MapStruct has +for name-based constructor matching. No @Default-style disambiguation among multiple constructors — ambiguous +constructor sets are refused (Beans.java:801-810) rather than selectable, but the writeBean hint plus the single-ctor +rule covers the practical cases. Verified limitation: no counterpart to MapStruct's @Default — a target with multiple +same-arity constructors cannot be disambiguated (records: full; single-ctor immutables: full with -parameters; +multi-constructor immutables: unsupported). + +Evidence: internal/src/main/java/io/github/eschizoid/telescope/internal/Records.java:273-307 (cached +canonical-constructor invoker per record class, components in canonical order, MethodHandle asSpreader); +conversion/Mapper.java:301 ('Records: rebuilds via the canonical constructor'); internal/Beans.java:710-718 +(ConstructorWriter: name-matched with -parameters, positional fallback) and :769-790 (auto-detected as autoWriter's 4th +rung with -parameters + name-alignment safety check, loud error otherwise); mapping/WriteHint.java:54-55 (CONSTRUCTOR +strategy docs); core/src/test/java/io/github/eschizoid/telescope/DeepMappingTest.java:42-59 (record↔record fixtures), +:209 (nested record mapper), :671-688 ('CONSTRUCTOR hint constructs an immutable all-args-only POJO' + auto-detection +test) + +## Policies & null handling + +### ✅ Unmapped-target policy + +**MapStruct:** unmappedTargetPolicy = ERROR/WARN/IGNORE at @Mapper or @MapperConfig level + +**telescope:** + +```java +// ERROR analog — Telescope.mapper is strict by default: unmatched fields on EITHER side throw at construction +final Mapper m = Telescope.mapper( + Order.class, + PartnerShippingLabel.class, + to(Order::orderNumber, PartnerShippingLabel::getTrackingReference), + drop(Order::metadata) +); // declare a field intentionally NOT mapped (@Mapping ignore analog) + +// IGNORE analog — lenient forward-only factory: unmatched targets get JLS defaults, unmatched sources are skipped +final ForwardMapper f = Telescope.mapperForward(Order.class, OrderDto.class); + +// Compile-time policy knob (javac): -Atelescope.verify=error|warn|off (default: error) +// Per-site exemption: @UncheckedMapping("reason") on the enclosing field/method/class +``` + +Telescope checks completeness twice: at mapper-construction time (always, fail-fast IllegalStateException) and at +compile time via MapperVerifierProcessor with a literal error|warn|off policy — the same three levels as MapStruct's +ERROR/WARN/IGNORE. Differences in idiom, not power: strict-vs-lenient is chosen per call site by factory (mapper = +strict bidirectional, mapperForward = lenient) rather than per-mapper attribute, and the compile-time severity is a +global javac -A flag plus per-site @UncheckedMapping, not per-mapper. drop(...) is the analog of @Mapping(target=..., +ignore=true). + +Evidence: core/src/main/java/io/github/eschizoid/telescope/DeepMap.java:124-132 (strict bijection — 'unmatched +fields on EITHER side throw at construction'); DeepMap.java:134-143 (mapperForward lenient, 'Matches MapStruct's default +behaviour'); core/src/main/java/io/github/eschizoid/telescope/mapping/Mapping.java:451-498 (drop(src) and drop(src, +Target.class) — 'declare it intentionally NOT mapped'); +codegen/src/main/java/io/github/eschizoid/telescope/codegen/MapperVerifierProcessor.java:61-63 +('-Atelescope.verify=error|warn|off (default error)', '@UncheckedMapping("reason")'), 108-118 (mode -> +Diagnostic.Kind.ERROR/WARNING/off), 325-328 (unmatchedTargets/unmatchedSources reported); +docs/adr/0012-compile-time-mapper-verification.md:1-40 + +### ⚠️ Null value strategies + +**MapStruct:** nullValueMappingStrategy / nullValuePropertyMappingStrategy (RETURN_NULL vs RETURN_DEFAULT, SET_TO_NULL +vs IGNORE on update) + +**telescope:** + +```java +import static io.github.eschizoid.telescope.mapping.Mapping.toOrElse; +import static io.github.eschizoid.telescope.mapping.NullHint.NullStrategy.DEFAULT; +import static io.github.eschizoid.telescope.mapping.NullHint.nullSourceValues; + +// SET_TO_DEFAULT analog — per-mapper hint: null source fields become "" / 0 / List.of() / Optional.empty() +final Mapper m = Telescope.mapper( + UserEntity.class, + UserDto.class, + nullSourceValues(DEFAULT), + toOrElse(UserEntity::displayName, UserDto::displayName, "(unnamed)") +); // per-field default (wins over hint) + +// SET_TO_NULL analog is the default (PROPAGATE) — no hint needed +// RETURN_NULL analog: mapper.forward(null) returns null +// IGNORE-on-update analog: patch overlays only non-null fields of the partial target +final UserEntity updated = m.patch(entity, dtoPatch); +``` + +Covered and tested: SET_TO_NULL (PROPAGATE, the default), SET_TO_DEFAULT (nullSourceValues(DEFAULT) with a +MapStruct-matching default table), RETURN_NULL (forward(null) -> null), IGNORE-on-update (patch skips null fields — +telescope's only patch mode), plus per-field defaultValue/defaultExpression analogs (toOrElse/toOrElseGet). Genuine +gaps: (1) no RETURN_DEFAULT for a whole-null source argument — forward(null) is always null; workaround is a caller-side +`src == null ? emptyDto() : m.forward(src)`; (2) no SET_TO_NULL toggle on update — patch always ignores nulls, so +'explicit null clears the field' PATCH semantics is not expressible; (3) DEFAULT substitutes only table leaf types — +record/bean/enum/custom-typed fields stay null unless a per-row toOrElse supplies a value; (4) DEFAULT is forward-only +(documented; backward preserves nulls). + +Evidence: core/src/main/java/io/github/eschizoid/telescope/mapping/NullHint.java:60-88 (nullSourceValues, +PROPAGATE='SET_TO_NULL', DEFAULT='SET_TO_DEFAULT', javadoc lines 8-10 names the MapStruct attributes); +internal/src/main/java/io/github/eschizoid/telescope/internal/NullDefaults.java:12-53 (per-type default table, +explicitly 'covers the surface MapStruct's RETURN_DEFAULT populates'); +core/src/main/java/io/github/eschizoid/telescope/DeepMap.java:990-995 + 1138-1144 (DEFAULT wraps every per-component Iso +via coalesceForward), 1573-1586 (assembleIso: forward(null) returns null = RETURN_NULL); +core/src/main/java/io/github/eschizoid/telescope/mapping/Mapping.java:243-318 (toOrElse/toOrElseGet per-field defaults); +core/src/main/java/io/github/eschizoid/telescope/conversion/Mapper.java:290-323 (patch: 'overlay the non-null fields of +partial ... leaving the rest of base untouched'); +core/src/test/java/io/github/eschizoid/telescope/NullStrategyTest.java:25-100 and tail (PROPAGATE default, DEFAULT +substitution for String/wrappers/BigDecimal/collections, bean targets, backward-unchanged) + +### ⚠️ Null check strategy + +**MapStruct:** nullValueCheckStrategy = ALWAYS — guard every property read before conversion/setter + +**telescope:** + +```java +// Engine-owned paths are ALWAYS null-guarded — no flag exists or is needed: +// auto-mapped nested pairs, containers, and telescope rows all pass null through safely. +// For custom conversion functions, guard per row: +Telescope.mapper(Src.class, Dst.class, + toOrElse(Src::code, Dst::code, "N/A", String::isBlank), // null-short-circuits BEFORE the predicate + to(Src::amountCents, Dst::amount, + a -> a == null ? null : BigDecimal.valueOf(a, 2), // manual guard inside custom fwd fn + b -> b == null ? null : b.movePointRight(2).longValue()), + nullSourceValues(DEFAULT)); // additionally skips wrapped fns on null for table-defaultable leaf types +``` + +Telescope's default posture is effectively ALWAYS for everything the engine owns: null sources, null nested objects, and +null containers propagate as null instead of NPE-ing, with no configuration. The gap is custom transform rows — the +user-supplied fwd/bwd functions in to(src, tgt, fwd, bwd) / toOneWay / via ARE invoked with null under the default +PROPAGATE strategy, and under DEFAULT the coalesce wrap is skipped for target types outside the NullDefaults table +(records/beans/enums/custom). There is no per-mapper 'never call my conversion with null' switch; the recipe is toOrElse +for the common cases or a null check inside the function. + +Evidence: core/src/main/java/io/github/eschizoid/telescope/DeepMap.java:1573-1586 (assembleIso: +`if (s == null) return null` on both directions — every auto record/bean pair guarded), 1306-1318 + 1360-1372 +(collection/map/list-lift isos: `if (src == null) return null`), 868-872 (telescope-to-telescope rows write +`srcT.find(s).orElse(null)` leniently on null intermediates); +internal/src/main/java/io/github/eschizoid/telescope/internal/optics/Iso.java:140-142 (coalesceForward: +`x == null ? defaultValue : inner.to(x)` — inner fn skipped on null), 149-163 (liftList null pass-through); +core/src/main/java/io/github/eschizoid/telescope/mapping/Mapping.java:269-273 + 314-317 (toOrElse/toOrElseGet +null-short-circuit before user predicates fire) + +### ⚠️ Conditional mapping + +**MapStruct:** @Condition presence-check methods (e.g. only map non-blank strings) + +**telescope:** + +```java +import static io.github.eschizoid.telescope.mapping.Mapping.*; + +Telescope.mapper(Order.class, OrderDto.class, + // per-field value predicate — 'only map non-blank strings', else default: + toOrElse(Order::promoCode, OrderDto::promoCode, "NONE", String::isBlank), + toOrElseGet(Order::traceId, OrderDto::traceId, () -> UUID.randomUUID().toString(), String::isBlank), + // whole-source predicate gating on deep writes / stamping rows: + when(order -> order.shipping() != null, + to(Telescope.of(Order.class).field(Order::shipping).field(Shipping::country), + OrderDto::shipCountry)), + when(order -> order.priority() == Priority.HIGH, + constant(OrderDto::expediteFlag, true))); +``` + +Both @Condition shapes have typed, tested analogs: per-field value predicates (toOrElse/toOrElseGet 4-arg, which +additionally null-short-circuit before the predicate) and whole-source predicates (when(...)), both forward-only like +@Condition. What's missing is MapStruct's blanket application: one @Condition method automatically gates EVERY property +of the matching type across the mapper, while telescope requires one row per gated field — real repetition on wide DTOs. +Also: when(...) wraps only telescope-based rows (plain to(srcAcc, tgtAcc)/via/drop rejected at construction with a +pointer to toOrElse), when(...) pins its predicate to the top-level source pair (not nested pairs), and there is no +conditional analog on the patch/update path. + +Evidence: core/src/main/java/io/github/eschizoid/telescope/mapping/Mapping.java:647-649 (when(predicate, inner) +factory; javadoc 599-646: 'closes MapStruct's @Condition for whole-source predicate gating', forward-only, nesting +rejected), 263-273 (toOrElse with `missing` predicate — 'Generalises ... to cover empty-string, empty-collection ... +MapStruct has no equivalent'), 308-318 (toOrElseGet with predicate); +core/src/main/java/io/github/eschizoid/telescope/mapping/Conditional.java:88-128 (predicate-gated row; construction-time +rejection of field-iso/nested inners with alternative hints), 44-57 (predicate evaluated once per top-level forward +call, thread-safety contract); core/src/test/java/io/github/eschizoid/telescope/MappingWhenTest.java:32-186 (gated +flat-to-nested, constant, compute, nested-to-flat, nested-to-nested, zip); +core/src/test/java/io/github/eschizoid/telescope/MappingOrElseTest.java (7 @Test methods) + +## Advanced + +### ⚠️ Config inheritance (@InheritConfiguration / @InheritInverseConfiguration) + +**MapStruct:** @InheritConfiguration reuses a base @Mapping set on a sibling method; @InheritInverseConfiguration +derives the reverse method from the forward one. + +**telescope:** + +```java +private static final MapStep[] AUDIT_COLUMNS = { + to(Entity::createdAt, Dto::createdAt), + to(Entity::updatedAt, Dto::updatedAt), +}; + +final Mapper mapper = Telescope.mapperBuilder(Entity.class, Dto.class) + .inherit(AUDIT_COLUMNS) // shared row group, reused across mappers + .add(to(Entity::email, Dto::emailAddress)) // mapper-specific rows + .build(); + +// @InheritInverseConfiguration is free: the SAME rows drive both directions — +Entity back = mapper.backward(mapper.forward(entity)); +``` + +Row groups reuse cleanly across mappers of the SAME (source, target) pair, and the inverse half is free (backward() +derives from the same rows). Verified limitation: a group inherited into a DIFFERENT type pair is silently dropped (rows +are bucketed by their decoded type pair; never-visited buckets are ignored) — cross-DTO-variant reuse per +MapperBuilder's javadoc example does not currently apply the shared rows. Tracked as a bug (#223). + +Evidence: core/src/main/java/io/github/eschizoid/telescope/conversion/MapperBuilder.java:10-14 (javadoc: "Closes +MapStruct's @InheritConfiguration"), :106-109 (inherit), :150-152 (build delegates to Telescope.mapper); +core/src/main/java/io/github/eschizoid/telescope/Telescope.java:602 (mapperBuilder factory); +core/src/test/java/io/github/eschizoid/telescope/MapperBuilderTest.java:70-87 (same AUDIT_COLUMNS group feeds two +different mappers); core/src/main/java/io/github/eschizoid/telescope/conversion/Mapper.java:330 (forward), :426-433 +(backward from the same Iso); README.md:442 ("Same Mapping.to(...) row works both ways") + +### ✅ Decorators (@DecoratedWith) + +**MapStruct:** @DecoratedWith(CustomDecorator.class) — an abstract decorator class wraps the generated mapper, +overriding selected methods and delegating to the injected original. + +**telescope:** + +```java +final Mapper mapper = Telescope.mapper(Entity.class, Dto.class) + .beforeForward((e) -> e.normalised()) // @BeforeMapping equivalent + .afterForward( + (src, dto) -> dto.withDisplayName(src.firstName() + " " + src.lastName()) // @AfterMapping(@MappingTarget) — source-aware + ) + .afterBackward((e) -> e.withLastModifiedAt(Instant.now())); + +// Full-method override is plain composition — the Mapper is a value, not a generated class: +Function decorated = (e) -> isSpecial(e) ? customDto(e) : mapper.forward(e); +``` + +Hooks compose left-to-right and return new immutable Mapper instances, so decoration happens at the definition site +(typically the @Bean/@Produces method) — no abstract class, no delegate injection, no annotation. Because Mapper is a +first-class value rather than a generated interface impl, whole-method conditional override is ordinary function +composition; the decorated mapper is simply what you register as the bean. The idiom is no weaker than @DecoratedWith — +it removes the constructor-injection ceremony MapStruct decorators need. + +Evidence: core/src/main/java/io/github/eschizoid/telescope/conversion/Mapper.java:455 (beforeForward, javadoc +:436-439 cites @BeforeMapping), :493 (afterForward, javadoc :472-476 cites @AfterMapping), :509 (source-aware BiFunction +afterForward, javadoc :497-502 cites @AfterMapping with @MappingTarget), :539 (beforeBackward), :568 (afterBackward); +:246-252 (asTelescope carries the hook chain), :286-288 (toForwardMapper carries hooks); :151-159 (public +Mapper.create(forward, backward, ...) for wholesale wrapping) + +### ✅ DI component models (componentModel = spring/cdi/jakarta) + +**MapStruct:** componentModel = "spring" / "cdi" / "jakarta" annotates the generated mapper impl so it is discovered as +an injectable bean. + +**telescope:** + +```java +// Spring Boot (telescope-spring-boot-starter on the classpath): +@Configuration +class Mappers { + @Bean + Mapper userMapper() { + return Telescope.mapper(UserEntity.class, UserDto.class); + } +} +// inject Mapper directly, or look up dynamically: +Mapper m = telescopeMapperRegistry.get(UserEntity.class, UserDto.class); + +// Quarkus/CDI (telescope-quarkus): same shape with @Produces — +@Produces Mapper userMapper() { return Telescope.mapper(...); } +``` + +Telescope mappers are values built in code, so the @Bean/@Produces method that MapStruct generates is instead the +one-liner where you build the mapper anyway — zero extra ceremony versus componentModel. Both starters add a +(sourceClass, targetClass)-indexed TelescopeMapperRegistry MapStruct has no analog for (duplicate pairs fail fast, +configurable via telescope.registry.fail-fast). Caveat: there is no dedicated plain-Jakarta-EE/jsr330 module — the +Quarkus module's auto-collection uses ArC's @All — but a Mapper is an ordinary object producible from a standard CDI +@Produces method in any container. + +Evidence: +spring-boot-starter/src/main/java/io/github/eschizoid/telescope/spring/TelescopeAutoConfiguration.java:34-60 +(@AutoConfiguration builds TelescopeMapperRegistry from every Mapper bean; javadoc :26-28 "declare @Bean Mapper +and it shows up in the registry"); +quarkus/src/main/java/io/github/eschizoid/telescope/quarkus/TelescopeProducer.java:23-42 (@ApplicationScoped producer, +ArC @All List> collector); README.md:373 (starter row in the comparison table) + +### ⚠️ Enum mapping (@ValueMapping / @EnumMapping) + +**MapStruct:** @ValueMapping(source = "X", target = "Y") per-constant renames with ANY_REMAINING/ANY_UNMAPPED defaults; +@EnumMapping name-transformation strategies (prefix/suffix/case). + +**telescope:** + +```java +// Same-name constants — exhaustiveness validated when the mapper is built (both directions): +enumTo(UserEntity::status, UserDto::status, EntityStatus.class, DtoStatus.class) + +// Renamed constants / ANY_REMAINING — a typed switch instead of @ValueMapping strings: +to(UserEntity::status, UserDto::status, + s -> switch (s) { case ARCHIVED -> DtoStatus.CLOSED; default -> DtoStatus.valueOf(s.name()); }, + d -> switch (d) { case CLOSED -> EntityStatus.ARCHIVED; default -> EntityStatus.valueOf(d.name()); }) +``` + +The by-name case is fully covered and arguably stronger than MapStruct — mismatched constant sets fail at mapper-build +time with a named diff, instead of at the first unlucky call. What's missing is the declarative sugar for asymmetric +enums: per-constant renames, @EnumMapping prefix/suffix/case name transformations, and ANY_REMAINING/ANY_UNMAPPED +defaults all require a hand-written (though javac-exhaustive and refactor-safe) switch or lambda inside a to(src, tgt, +fwd, bwd) row; enumTo itself deliberately rejects non-bijective enum pairs. Workaround quality is high, but it is manual +code, not a factory. + +Evidence: core/src/main/java/io/github/eschizoid/telescope/mapping/Mapping.java:150-155 (enumTo javadoc: "Closes +MapStruct's @ValueMapping gap for the common 'status enums that line up by name' case"), :175-188 (enumTo impl over +Enum.valueOf both ways), :190-225 (factory-time exhaustiveness diff naming missing constants and pointing to to(src, +tgt, fwd, bwd)); core/src/test/java/io/github/eschizoid/telescope/MappingEnumToTest.java:62 (happy-path round-trip), +:91-113 (mismatch diagnostics + escape-hatch message); README.md:369, :988 (comparison rows) + +### ⚠️ Subclass mapping (@SubclassMapping) + +**MapStruct:** @SubclassMapping(source = Sub.class, target = SubDto.class) — polymorphic dispatch over an +abstract/sealed source hierarchy, inheriting the parent method's mapping config. + +**telescope:** + +```java +sealed interface Payment permits CreditCard, BankTransfer, Crypto {} + +Function dispatch = Match.of(Payment.class) + .when(CreditCard.class, creditCardMapper::forward) + .when(BankTransfer.class, bankMapper::forward) + .when(Crypto.class, cryptoMapper::forward) + .exhaustive(); // throws at build time naming any uncovered permit; .partial() opts out + +// codegen sibling: @Bridge on a sealed interface pair emits a pattern-match switch over the permits +``` + +For sealed hierarchies the coverage is real and in one way stronger — .exhaustive() names every uncovered permit, and +the @Bridge codegen path emits a compile-time switch for sealed-to-sealed pairs. Two genuine gaps versus +@SubclassMapping: (1) Match.of() rejects non-sealed roots, so plain abstract-class hierarchies (common in legacy JPA +models) have no dispatcher — closest workaround is a hand-rolled instanceof chain; (2) per-permit handlers are +independent mappers with no automatic inheritance of shared parent-field config (mitigable by sharing a MapStep[] group +via mapperBuilder().inherit(...), but that is manual). The project's own README lists full @SubclassMapping as a +MapStruct-only shape. + +Evidence: core/src/main/java/io/github/eschizoid/telescope/conversion/Match.java:41-153 (Match builder; :60-67 +rejects non-sealed roots; :101-120 exhaustive() verifies coverage via Class.getPermittedSubclasses; :127-129 partial(); +:136-153 Prism.downcast-routed dispatch); core/src/test/java/io/github/eschizoid/telescope/MatchTest.java:33-77 +(exhaustive dispatch + missing-permit diagnostics), :103-118 (partial escape hatch); +codegen/src/main/java/io/github/eschizoid/telescope/codegen/BridgeProcessor.java:1789-1810 (generateSealed: +sealed-source bridge emits pattern-match switch, requires every permit @Bridge-annotated to a permit of the sealed +target); README.md:456-457 (README concedes "full @SubclassMapping polymorphic dispatch" to MapStruct) From 46d20f8b77f3bfbd2070e7ca3b6335c08f8355aa Mon Sep 17 00:00:00 2001 From: mariano Date: Wed, 8 Jul 2026 23:35:28 -0500 Subject: [PATCH 2/6] =?UTF-8?q?docs:=20review-fleet=20fixes=20=E2=80=94=20?= =?UTF-8?q?working=20anchors,=20rendered=20generics,=20honest=20wording?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Parity matrix: - Detail headings are now plain feature names, so every summary-table anchor resolves under GitHub's slugger (emoji headings produced leading-dash slugs; generics in headings were swallowed as HTML). Status moved into each section body. - Bare generic type expressions in prose are backticked — previously rendered as vanished HTML tags (List showed as 'List'). - De-puffed three 'strictly better/safer' claims and acknowledged the broader-scope trade-off of pair-keyed rename rows; the tally line now says 'no feature area is unreachable' since the collection-merge residual gap has no workaround. Migration guide: - constant/defaultValue row now names the real first-class factories (constant(...), toOrElse(...)) instead of a hand-rolled transform row. - ignore=true row distinguishes source-side drop(...) from target-only fields. - Merge example uses the bean accessor shape (CustomerDto::getCity). - WARN row states the silent-vs-warning nuance (-Atelescope.verify=warn is the build-time analog), aligned with the matrix. - Introspection links point at the README (the matrix has no introspection row) and the 'every row above' claim is scoped to mapping rows. --- docs/mapstruct-migration.md | 42 ++--- docs/mapstruct-parity.md | 299 ++++++++++++++++++------------------ 2 files changed, 174 insertions(+), 167 deletions(-) diff --git a/docs/mapstruct-migration.md b/docs/mapstruct-migration.md index 402ce945..f80d3a9b 100644 --- a/docs/mapstruct-migration.md +++ b/docs/mapstruct-migration.md @@ -22,8 +22,8 @@ public interface CustomerMapper { ``` 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](mapstruct-parity.md) — which is also how you _verify_ each migration -step below. +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 @@ -39,25 +39,25 @@ 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)` | -| `@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` | a transform row whose function returns the constant / falls back on null | -| type conversion (`String` ↔ `LocalDate`, …) | `to(A::birthDate, B::getBirthDate, LocalDate::parse, LocalDate::toString)` | -| 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::city))` | -| `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, matching MapStruct's default posture | -| `componentModel = "spring"/"cdi"` | plain `@Bean` / `@Produces Mapper` + the starter's `TelescopeMapperRegistry` | -| generated `…MapperImpl` you read to debug | `mapper.explain()` / `mapper.trace(input)` / flip a log level — [introspection](mapstruct-parity.md) | - -Every row above is expanded — with evidence and limitations — in the [parity matrix](mapstruct-parity.md). +| 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 (`String` ↔ `LocalDate`, …) | `to(A::birthDate, B::getBirthDate, LocalDate::parse, LocalDate::toString)` | +| 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; add `-Atelescope.verify=warn` for the build-time warning) | +| `componentModel = "spring"/"cdi"` | plain `@Bean` / `@Produces Mapper` + 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) diff --git a/docs/mapstruct-parity.md b/docs/mapstruct-parity.md index da5b3a72..24d4539a 100644 --- a/docs/mapstruct-parity.md +++ b/docs/mapstruct-parity.md @@ -5,51 +5,51 @@ telescope's actual source and tests (citations below), then adversarially re-ver every citation — two verdicts were downgraded and two snippets corrected in that pass. Audited against MapStruct 1.6.3 semantics and telescope `main`. -**Legend:** ✅ full — the use case is covered, possibly via a different idiom that is no worse · ⚠️ partial — -achievable, with the real limitation stated in its notes · ❌ missing. +**Legend:** ✅ full — the use case is covered, possibly via a different idiom that is no worse · ⚠️ partial — the core +use case works, with the real limitation stated in the row's notes · ❌ missing. ## Summary -| Area | Feature | Status | -| ------------------------- | -------------------------------------------------------------------------------------------------------------------------------- | :----: | -| Core mapping | [Implicit same-name mapping](#implicit-same-name-mapping) | ✅ | -| Core mapping | [Explicit rename](#explicit-rename) | ✅ | -| Core mapping | [Nested source path](#nested-source-path) | ✅ | -| Core mapping | [Multiple source parameters](#multiple-source-parameters) | ⚠️ | -| Conversions | [Implicit type conversions](#implicit-type-conversions) | ⚠️ | -| Conversions | [Format strings](#format-strings) | ⚠️ | -| Conversions | [Expressions](#expressions) | ✅ | -| Conversions | [Constants and defaults](#constants-and-defaults) | ✅ | -| Collections & containers | [Collection element mapping](#collection-element-mapping-listentity---listdto-reusing-the-element-mapping) | ✅ | -| Collections & containers | [Map mapping](#map-mapping-mapkv-valuekey-mapping-mapmappingvaluedateformat) | ⚠️ | -| Collections & containers | [Collection target strategies](#collection-target-strategies-collectionmappingstrategy-mappingtarget-collection-merge-semantics) | ⚠️ | -| Collections & containers | [Stream support](#stream-support-streama---listb--stream-mappings) | ⚠️ | -| Lifecycle & customization | [Before/after hooks](#beforeafter-hooks-beforemapping--aftermapping) | ✅ | -| Lifecycle & customization | [Context parameters](#context-parameters-context) | ⚠️ | -| Lifecycle & customization | [Qualifiers](#qualifiers-named--custom-qualifier) | ✅ | -| Lifecycle & customization | [Mapper composition](#mapper-composition-mapperuses) | ✅ | -| Object creation & update | [In-place update methods](#in-place-update-methods-mappingtarget) | ✅ | -| Object creation & update | [Object factories](#object-factories-objectfactory) | ⚠️ | -| Object creation & update | [Builder support](#builder-support-auto-detected-builders) | ⚠️ | -| Object creation & update | [Records and constructor mapping](#records-and-constructor-mapping) | ⚠️ | -| Policies & null handling | [Unmapped-target policy](#unmapped-target-policy) | ✅ | -| Policies & null handling | [Null value strategies](#null-value-strategies) | ⚠️ | -| Policies & null handling | [Null check strategy](#null-check-strategy) | ⚠️ | -| Policies & null handling | [Conditional mapping](#conditional-mapping) | ⚠️ | -| Advanced | [Config inheritance](#config-inheritance-inheritconfiguration--inheritinverseconfiguration) | ⚠️ | -| Advanced | [Decorators](#decorators-decoratedwith) | ✅ | -| Advanced | [DI component models](#di-component-models-componentmodel--springcdijakarta) | ✅ | -| Advanced | [Enum mapping](#enum-mapping-valuemapping--enummapping) | ⚠️ | -| Advanced | [Subclass mapping](#subclass-mapping-subclassmapping) | ⚠️ | - -**Tally: 13 ✅ · 16 ⚠️ · 0 ❌** — no MapStruct feature is unreachable; every ⚠️ has a working idiom with its limitation -stated. +| Area | Feature | Status | +| ------------------------- | ------------------------------------------------------------------- | :----: | +| Core mapping | [Implicit same-name mapping](#implicit-same-name-mapping) | ✅ | +| Core mapping | [Explicit rename](#explicit-rename) | ✅ | +| Core mapping | [Nested source path](#nested-source-path) | ✅ | +| Core mapping | [Multiple source parameters](#multiple-source-parameters) | ⚠️ | +| Conversions | [Implicit type conversions](#implicit-type-conversions) | ⚠️ | +| Conversions | [Format strings](#format-strings) | ⚠️ | +| Conversions | [Expressions](#expressions) | ✅ | +| Conversions | [Constants and defaults](#constants-and-defaults) | ✅ | +| Collections & containers | [Collection element mapping](#collection-element-mapping) | ✅ | +| Collections & containers | [Map mapping](#map-mapping) | ⚠️ | +| Collections & containers | [Collection target strategies](#collection-target-strategies) | ⚠️ | +| Collections & containers | [Stream support](#stream-support) | ⚠️ | +| Lifecycle & customization | [Before/after hooks](#beforeafter-hooks) | ✅ | +| Lifecycle & customization | [Context parameters](#context-parameters) | ⚠️ | +| Lifecycle & customization | [Qualifiers](#qualifiers) | ✅ | +| Lifecycle & customization | [Mapper composition](#mapper-composition) | ✅ | +| Object creation & update | [In-place update methods](#in-place-update-methods) | ✅ | +| Object creation & update | [Object factories](#object-factories) | ⚠️ | +| Object creation & update | [Builder support](#builder-support) | ⚠️ | +| Object creation & update | [Records and constructor mapping](#records-and-constructor-mapping) | ⚠️ | +| Policies & null handling | [Unmapped-target policy](#unmapped-target-policy) | ✅ | +| Policies & null handling | [Null value strategies](#null-value-strategies) | ⚠️ | +| Policies & null handling | [Null check strategy](#null-check-strategy) | ⚠️ | +| Policies & null handling | [Conditional mapping](#conditional-mapping) | ⚠️ | +| Advanced | [Config inheritance](#config-inheritance) | ⚠️ | +| Advanced | [Decorators](#decorators) | ✅ | +| Advanced | [DI component models](#di-component-models) | ✅ | +| Advanced | [Enum mapping](#enum-mapping) | ⚠️ | +| Advanced | [Subclass mapping](#subclass-mapping) | ⚠️ | + +**Tally: 13 ✅ · 16 ⚠️ · 0 ❌** — no feature area is unreachable; every ⚠️ row has a working idiom for its core use +case, with the residual gaps named in its notes. ## Core mapping -### ✅ Implicit same-name mapping +### Implicit same-name mapping -**MapStruct:** Unannotated properties map by name automatically +**Status: ✅ full** · **MapStruct:** Unannotated properties map by name automatically **telescope:** @@ -76,9 +76,9 @@ generated-mapper default'); core/src/test/java/io/github/eschizoid/telescope/Dee same-name deep copy — no overrides needed': Telescope.map(SameAddrEntity.class, SameAddrDto.class) with zero rows); docs/adr/0002-no-fuzzy-auto-mapping.md -### ✅ Explicit rename +### Explicit rename -**MapStruct:** @Mapping(source = "email", target = "contactEmail") +**Status: ✅ full** · **MapStruct:** @Mapping(source = "email", target = "contactEmail") **telescope:** @@ -92,10 +92,10 @@ Mapper mapper = Telescope.mapper( ); // typed method refs, no strings ``` -Strictly better than MapStruct's string attributes: source/target are compile-checked Serializable method references, so -IDE rename refactors follow and typos fail at javac, not at annotation-processing. One rename row is keyed by -(sourceClass, targetClass) and applies at every depth where the pair recurses — MapStruct needs a @Mapping per mapper -method. +Stronger than MapStruct's string attributes on refactor safety: source/target are compile-checked Serializable method +references, so IDE rename refactors follow and typos fail at javac, not at annotation-processing. One rename row is +keyed by (sourceClass, targetClass) and applies at every depth where the pair recurses — less repetition than a @Mapping +per mapper method, at the cost of broader scope when two usages of the same pair need different renames. Evidence: core/src/main/java/io/github/eschizoid/telescope/mapping/Mapping.java:97-100 (to(src, tgt) same-typed rename) and 111-118 (to(src, tgt, fwd, bwd) typed transform for renames that also change type); @@ -103,9 +103,10 @@ core/src/test/java/io/github/eschizoid/telescope/DeepMappingTest.java:122-128 (t CompanyDto::since) rename in 5-level nesting); core/src/main/java/io/github/eschizoid/telescope/Telescope.java:509-517 (javadoc: a rename row applies wherever recursion lands on that type pair) -### ✅ Nested source path +### Nested source path -**MapStruct:** @Mapping(source = "customer.address.city", target = "city") — dotted path into nested source +**Status: ✅ full** · **MapStruct:** @Mapping(source = "customer.address.city", target = "city") — dotted path into +nested source **telescope:** @@ -128,15 +129,15 @@ instead of dotted strings, so javac checks every hop. Rows using Telescope paths pair only — same scope as MapStruct's per-method @Mapping. Known edge: flat-source -> nested-target intermediate allocation needs a via(...) workaround for bean intermediates lacking a no-arg ctor/builder. -Evidence: core/src/main/java/io/github/eschizoid/telescope/mapping/Mapping.java:361-379 (to(Telescope, -Accessor) — javadoc: 'Closes MapStruct's @Mapping(source = "a.b.c", target = "flat")'), 381-402 (both-nested +Evidence: core/src/main/java/io/github/eschizoid/telescope/mapping/Mapping.java:361-379 (to(`Telescope`, +`Accessor`) — javadoc: 'Closes MapStruct's @Mapping(source = "a.b.c", target = "flat")'), 381-402 (both-nested to(Telescope, Telescope)), 320-359 (nested-target mirror); core/src/test/java/io/github/eschizoid/telescope/TelescopeMappingTest.java:181-193 (forward reads nested source path, writes flat target), 200-214 (both-nested), 158-174 (codegen navigators on both sides) -### ⚠️ Multiple source parameters +### Multiple source parameters -**MapStruct:** CustomerDto toDto(Customer c, Address a) — one target built from several sources +**Status: ⚠️ partial** · **MapStruct:** CustomerDto toDto(Customer c, Address a) — one target built from several sources **telescope:** @@ -169,10 +170,11 @@ forward), 97-104 (arity 3), 121-130 (arity 5, single factory) ## Conversions -### ⚠️ Implicit type conversions +### Implicit type conversions -**MapStruct:** Automatic conversions between source/target types: primitive<->wrapper autoboxing, String<->number, -enum<->String, date/time<->String, widening numeric conversions — all silent, no declaration needed. +**Status: ⚠️ partial** · **MapStruct:** Automatic conversions between source/target types: primitive<->wrapper +autoboxing, `String<->`number, enum<->String, date/time<->String, widening numeric conversions — all silent, no +declaration needed. **telescope:** @@ -189,25 +191,25 @@ Telescope.mapper(Src.class, Dst.class, enumTo(Src::status, Dst::status, SrcStatus.class, DstStatus.class)); // enum<->enum by name, exhaustiveness-checked ``` -Primitive<->wrapper is genuinely automatic and matches MapStruct's null->0/false behavior (tested). But String<->number, -enum<->String, and date/time conversions are deliberately NOT implicit (ADR-0002 'no fuzzy auto-mapping'): a same-name -field with mismatched types throws at mapper-build time with a message naming the fix. The workaround is one explicit -to(src, tgt, fwd, bwd) row per pair — compile-typed, but a MapStruct migrator must write rows MapStruct generated -silently. enum<->enum by name gets first-class sugar (enumTo, with build-time exhaustiveness MapStruct lacks); -enum<->String does not. +`Primitive<->`wrapper is genuinely automatic and matches MapStruct's null->0/false behavior (tested). But +`String<->`number, enum<->String, and date/time conversions are deliberately NOT implicit (ADR-0002 'no fuzzy +auto-mapping'): a same-name field with mismatched types throws at mapper-build time with a message naming the fix. The +workaround is one explicit to(src, tgt, fwd, bwd) row per pair — compile-typed, but a MapStruct migrator must write rows +MapStruct generated silently. enum<->enum by name gets first-class sugar (enumTo, with build-time exhaustiveness +MapStruct lacks); enum<->String does not. Evidence: core/src/main/java/io/github/eschizoid/telescope/DeepMap.java:1016-1035 (Identity / PrimitiveWrapper -autobox with JLS-default null guard / CollectionCopy branches), DeepMap.java:1049-1069 (Optional<->nullable lift); +autobox with JLS-default null guard / CollectionCopy branches), DeepMap.java:1049-1069 (`Optional<->`nullable lift); core/src/test/java/io/github/eschizoid/telescope/MigrationRegressionTest.java:472-500 (primitive<->wrapper auto-boxed, null boxed -> JLS default), MigrationRegressionTest.java:2815-2845 (same-name Integer vs String pair fails fast pointing at 'to(src, tgt, forward, backward)'; 4-arg transform is the documented fix); core/src/main/java/io/github/eschizoid/telescope/mapping/Mapping.java:175-188 (enumTo); docs/adr/0002-no-fuzzy-auto-mapping.md (exact name+type only, explicit rows otherwise) -### ⚠️ Format strings +### Format strings -**MapStruct:** @Mapping(numberFormat = "$#.00") / @Mapping(dateFormat = "dd.MM.yyyy") — annotation attribute, MapStruct -generates DecimalFormat/SimpleDateFormat code for both directions. +**Status: ⚠️ partial** · **MapStruct:** @Mapping(numberFormat = "$#.00") / @Mapping(dateFormat = "dd.MM.yyyy") — +annotation attribute, MapStruct generates DecimalFormat/SimpleDateFormat code for both directions. **telescope:** @@ -238,10 +240,10 @@ qualifier dispatch, DateTimeFormatter example at lines 83-94); README.md:981-991 to @Mapping qualifiedBy); core/src/test/java/io/github/eschizoid/telescope/MigrationRegressionTest.java:2826-2845 (number<->String transform round-trip) -### ✅ Expressions +### Expressions -**MapStruct:** @Mapping(expression = "java(...)") for computed target values and defaultExpression = "java(...)" for -lazily-computed null fallbacks; string-templated Java inside an annotation. +**Status: ✅ full** · **MapStruct:** @Mapping(expression = "java(...)") for computed target values and defaultExpression += "java(...)" for lazily-computed null fallbacks; string-templated Java inside an annotation. **telescope:** @@ -257,25 +259,25 @@ Telescope.mapper(Order.class, OrderDto.class, // or per-field: toOneWay(Order::total, OrderDto::totalText, t -> "$" + t) ``` -Strictly better idiom than MapStruct's stringly expression: plain typed Java, javac-checked, refactor-safe, no -expression parser. compute() takes a Supplier (no source arg) — source-dependent expressions route through toOneWay(src, -tgt, fn) for one source field or the typed afterForward((src, dto) -> ...) hook for multi-field derivation. -compute/constant rows are forward-only by design (backward drops the slot). Codegen @Compute requires a top-level -Supplier class (no lambdas in annotations) — slightly heavier than runtime form. +A better idiom than MapStruct's stringly expression: plain typed Java, javac-checked, refactor-safe, no expression +parser. compute() takes a Supplier (no source arg) — source-dependent expressions route through toOneWay(src, tgt, fn) +for one source field or the typed afterForward((src, dto) -> ...) hook for multi-field derivation. compute/constant rows +are forward-only by design (backward drops the slot). Codegen @Compute requires a top-level Supplier class (no lambdas +in annotations) — slightly heavier than runtime form. Evidence: core/src/main/java/io/github/eschizoid/telescope/mapping/Mapping.java:527-553 (compute(tgt, Supplier), javadoc names the expression=java(Instant.now()) gap), Mapping.java:582-597 (nested-target compute), Mapping.java:276-318 (toOrElseGet closes defaultExpression, plus predicate-gated overload), Mapping.java:142-148 (toOneWay per-field function); core/src/main/java/io/github/eschizoid/telescope/conversion/Mapper.java:497-522 -(afterForward(BiFunction) source-aware hook, typed @AfterMapping analog); +(afterForward(`BiFunction`) source-aware hook, typed @AfterMapping analog); core/src/test/java/io/github/eschizoid/telescope/MappingConstantComputeTest.java:104-148 (fresh-per-call, forward-only semantics pinned); core/src/main/java/io/github/eschizoid/telescope/annotations/Compute.java:37-51 (codegen @Compute(using = Supplier.class) for @Bridge); README.md:991 -### ✅ Constants and defaults +### Constants and defaults -**MapStruct:** @Mapping(target = "x", constant = "fixed") stamps a literal; @Mapping(target = "x", defaultValue = -"fallback") substitutes when source is null. +**Status: ✅ full** · **MapStruct:** @Mapping(target = "x", constant = "fixed") stamps a literal; @Mapping(target = "x", +defaultValue = "fallback") substitutes when source is null. **telescope:** @@ -314,9 +316,10 @@ core/src/main/java/io/github/eschizoid/telescope/annotations/Constant.java:41-50 ## Collections & containers -### ✅ Collection element mapping (List -> List reusing the element mapping) +### Collection element mapping -**MapStruct:** List map(List cars) — generated loop reuses the element mapping method automatically +**Status: ✅ full** · **MapStruct:** `List` map(`List` cars) — generated loop reuses the element mapping +method automatically **telescope:** @@ -337,22 +340,23 @@ final Mapper teamMapper2 = Telescope.mapper( ``` Element mapping is reused automatically (same-name recursion) or explicitly (via(...) with a pre-built element Mapper), -including nested containers like List> and Map>. Bidirectional round-trip comes free. One honesty -caveat: auto-lift requires SAME-kind containers on both sides (PairingRules.java:79 — srcView.kind() == tgtView.kind()); -MapStruct's List -> Set cross-kind copy needs an explicit to(src, tgt, fwd, bwd) row in telescope. Target concrete class -(ArrayList/LinkedList/CopyOnWriteArrayList/...) is honored via listAllocatorFor (DeepMap.java:1441-1461). +including nested containers like `List>` and `Map>`. Bidirectional round-trip comes free. One +honesty caveat: auto-lift requires SAME-kind containers on both sides (PairingRules.java:79 — srcView.kind() == +tgtView.kind()); MapStruct's List -> Set cross-kind copy needs an explicit to(src, tgt, fwd, bwd) row in telescope. +Target concrete class (ArrayList/LinkedList/CopyOnWriteArrayList/...) is honored via listAllocatorFor +(DeepMap.java:1441-1461). Evidence: core/src/main/java/io/github/eschizoid/telescope/DeepMap.java:65-73 (engine javadoc: List/Set/Map-values/Optional lift the element Iso, containers nest to any depth), DeepMap.java:1093-1124 (LiftContainer dispatch recursing on element type), DeepMap.java:1213-1268 (liftViaIfNeeded: element-level Mapper auto-lifted through the accessor's container); core/src/main/java/io/github/eschizoid/telescope/mapping/Mapping.java:427-449 (via javadoc + factory: 'List pair, auto-lifts'); core/src/test/java/io/github/eschizoid/telescope/DeepMappingTest.java:125-178 -(5-level nesting, element rename fires inside List of List of List), 264-289 (List> and Map> -auto-lift), 342-372 (via(...) lifts Mapper through a List accessor pair, round-trips) +(5-level nesting, element rename fires inside List of List of List), 264-289 (`List>` and `Map>` +auto-lift), 342-372 (via(...) lifts `Mapper` through a List accessor pair, round-trips) -### ⚠️ Map mapping (Map value/key mapping, @MapMapping(valueDateFormat=...)) +### Map mapping -**MapStruct:** Map map(Map m); +**Status: ⚠️ partial** · **MapStruct:** `Map` map(`Map` m); @MapMapping(keyDateFormat/valueDateFormat/keyQualifiedBy/valueQualifiedBy) **telescope:** @@ -385,14 +389,14 @@ HashMap/LinkedHashMap/TreeMap/ConcurrentHashMap/...; EnumMap rejected at plan ti internal/src/main/java/io/github/eschizoid/telescope/internal/pairing/PairingRules.java:80-91 (mismatched key types -> Incompatible), 119-122 (non-class key type arg means the Map is not treated as liftable); core/src/test/java/io/github/eschizoid/telescope/DeepMappingTest.java:172-174 (Map values recursed, keys preserved), -280-289 (Map> auto-lifts); +280-289 (`Map>` auto-lifts); core/src/test/java/io/github/eschizoid/telescope/DeepMapCoverageTest.java:77,152 (key-type mismatch rejection asserted) -### ⚠️ Collection target strategies (CollectionMappingStrategy, @MappingTarget collection merge semantics) +### Collection target strategies -**MapStruct:** CollectionMappingStrategy = ACCESSOR_ONLY / SETTER_PREFERRED / ADDER_PREFERRED / TARGET_IMMUTABLE; -@MappingTarget updates an existing target's collections +**Status: ⚠️ partial** · **MapStruct:** CollectionMappingStrategy = ACCESSOR_ONLY / SETTER_PREFERRED / ADDER_PREFERRED / +TARGET_IMMUTABLE; @MappingTarget updates an existing target's collections **telescope:** @@ -429,10 +433,10 @@ core/src/main/java/io/github/eschizoid/telescope/mapping/WriteHint.java:45-98 (w BUILDER/SETTERS/FIELDS/CONSTRUCTOR — no ADDER); grep for 'adder' across core/ and codegen/ returned only WriteStrategy ladder docs (no adder support anywhere) -### ⚠️ Stream support (Stream -> List / Stream mappings) +### Stream support -**MapStruct:** List map(Stream cars) — generated terminal collect; Stream-typed sources/targets in mapper -signatures +**Status: ⚠️ partial** · **MapStruct:** `List` map(`Stream` cars) — generated terminal collect; +Stream-typed sources/targets in mapper signatures **telescope:** @@ -443,7 +447,7 @@ final Mapper carMapper = Telescope.mapper(Car.class, CarDto.class); final List dtos = cars.map(carMapper::forward).toList(); // Stream -> List ``` -No Stream support in the mapping engine: Stream is not a recognized container kind, so a Stream-typed record +No Stream support in the mapping engine: Stream is not a recognized container kind, so a `Stream`-typed record component or bean property cannot be auto-mapped (the pair is rejected as incompatible shapes; workaround is a manual to(src, tgt, fwd, bwd) row that collects/re-streams). The dominant real-world case — converting a Stream at a call site — is a one-liner with mapper::forward that is arguably no worse than declaring a Stream method on a MapStruct interface, @@ -459,9 +463,10 @@ README.md:805) ## Lifecycle & customization -### ✅ Before/after hooks (@BeforeMapping / @AfterMapping) +### Before/after hooks -**MapStruct:** @BeforeMapping / @AfterMapping callback methods invoked around the generated mapping +**Status: ✅ full** · **MapStruct:** @BeforeMapping / @AfterMapping callback methods invoked around the generated +mapping **telescope:** @@ -474,7 +479,7 @@ Mapper mapper = Telescope.mapper(Entity.class, Dto.class, to(Entity ``` Four symmetric hooks per direction, each returning a new immutable Mapper; BiFunction overloads give the hook both -source and structural result (MapStruct's @AfterMapping-with-source pattern). Hooks are Function not Consumer, so +source and structural result (MapStruct's @AfterMapping-with-source pattern). Hooks are `Function` not Consumer, so they work for immutable records too — arguably richer than MapStruct, where mutable-target hooks are the norm. Hooks survive .asTelescope() and chain left-to-right. @@ -486,9 +491,10 @@ direction-laziness, asTelescope() propagation); core/src/test/java/io/github/eschizoid/telescope/MigrationRegressionTest.java:1239, 1268, 1885; README.md:372 (parity-table row) -### ⚠️ Context parameters (@Context) +### Context parameters -**MapStruct:** @Context param threaded through nested mappings without being mapped itself (e.g. Locale, cycle-tracking) +**Status: ⚠️ partial** · **MapStruct:** @Context param threaded through nested mappings without being mapped itself +(e.g. Locale, cycle-tracking) **telescope:** @@ -525,10 +531,10 @@ mutual recursion map without StackOverflow, zero user code); core/src/main/java/io/github/eschizoid/telescope/mapping/Mapping.java:111 (to(src, tgt, fwd, bwd) typed-transform row where a closure captures context) -### ✅ Qualifiers (@Named / custom @Qualifier) +### Qualifiers -**MapStruct:** @Named("formatter") / custom @Qualifier to pick between multiple candidate mapping methods for the same -type pair +**Status: ✅ full** · **MapStruct:** @Named("formatter") / custom @Qualifier to pick between multiple candidate mapping +methods for the same type pair **telescope:** @@ -545,10 +551,10 @@ public record UserEntity(String id, Instant expiresAt, Instant createdAt) {} ``` The ambiguity qualifiers solve doesn't arise at runtime: each row names its converter/mapper by reference, resolved by -javac — strictly safer than @Named string matching. Codegen side has the literal @Named-equivalent via @Transform(using, -method) (forward-only, same asymmetry as a single MapStruct qualified method). One corner: the DI -TelescopeMapperRegistry allows one mapper per (src,tgt) pair — two semantically-different mappers for the same pair must -be injected directly with the framework's own @Qualifier, bypassing the registry. +javac — safer than @Named string matching. Codegen side has the literal @Named-equivalent via @Transform(using, method) +(forward-only, same asymmetry as a single MapStruct qualified method). One corner: the DI TelescopeMapperRegistry allows +one mapper per (src,tgt) pair — two semantically-different mappers for the same pair must be injected directly with the +framework's own @Qualifier, bypassing the registry. Evidence: core/src/main/java/io/github/eschizoid/telescope/annotations/Transform.java:59-100 (method() attribute — javadoc explicitly says it "unlocks MapStruct's @Named qualifier-dispatch pattern", emits direct @@ -557,10 +563,10 @@ UsingClass.methodName(value) static call); core/src/main/java/io/github/eschizoi spring-boot-starter/src/main/java/io/github/eschizoid/telescope/spring/TelescopeMapperRegistry.java:44-66 (duplicate (src,tgt) pairs throw with a message directing to Spring @Qualifier + direct injection) -### ✅ Mapper composition (@Mapper(uses = ...)) +### Mapper composition -**MapStruct:** @Mapper(uses = OtherMapper.class) — delegating nested type conversions to other mappers or static helper -methods +**Status: ✅ full** · **MapStruct:** @Mapper(uses = OtherMapper.class) — delegating nested type conversions to other +mappers or static helper methods **telescope:** @@ -599,10 +605,10 @@ precedence over auto-recursion); core/src/test/java/io/github/eschizoid/telescop ## Object creation & update -### ✅ In-place update methods (@MappingTarget) +### In-place update methods -**MapStruct:** void update(@MappingTarget Entity target, Dto source) — mutate an existing bean instead of constructing a -fresh one +**Status: ✅ full** · **MapStruct:** void update(@MappingTarget Entity target, Dto source) — mutate an existing bean +instead of constructing a fresh one **telescope:** @@ -627,10 +633,10 @@ with UnsupportedOperationException at :394-400); Mapper.java:290-323 (patch() sp core/src/test/java/io/github/eschizoid/telescope/MapperIntoTest.java:16 ('Pins Mapper.into(target, source) — the @MappingTarget equivalent'), :136-138 (assertSame identity preservation), :150-153 (repeatable mutation) -### ⚠️ Object factories (@ObjectFactory) +### Object factories -**MapStruct:** @ObjectFactory — a user method that instantiates the target (custom constructor args, EntityManager -lookup, DI-provided instance) which the generated mapper then populates +**Status: ⚠️ partial** · **MapStruct:** @ObjectFactory — a user method that instantiates the target (custom constructor +args, EntityManager lookup, DI-provided instance) which the generated mapper then populates **telescope:** @@ -660,10 +666,10 @@ factory hook); core/src/main/java/io/github/eschizoid/telescope/Telescope.java:4 conversion/Mapper.java:391-423 (into() covers the load-existing-instance use case); grep for 'ObjectFactory' across _.java/_.md returned zero hits -### ⚠️ Builder support (auto-detected builders) +### Builder support -**MapStruct:** Auto-detected builder(): Immutables, Lombok @Builder, protobuf builders; customizable via BuilderProvider -SPI and @Builder(builderMethod=...) +**Status: ⚠️ partial** · **MapStruct:** Auto-detected builder(): Immutables, Lombok @Builder, protobuf builders; +customizable via BuilderProvider SPI and @Builder(builderMethod=...) **telescope:** @@ -696,10 +702,10 @@ LombokFocusProcessorTest.java:53-68 (@Builder and @Value+@Builder navigators ver core/src/test/java/io/github/eschizoid/telescope/DeepMappingTest.java:576-712 (writeBean BUILDER/FIELDS/CONSTRUCTOR hint tests) -### ⚠️ Records and constructor mapping +### Records and constructor mapping -**MapStruct:** Constructor-based target population for records and immutable classes (parameter-name matching, @Default -constructor selection) +**Status: ⚠️ partial** · **MapStruct:** Constructor-based target population for records and immutable classes +(parameter-name matching, @Default constructor selection) **telescope:** @@ -736,9 +742,9 @@ test) ## Policies & null handling -### ✅ Unmapped-target policy +### Unmapped-target policy -**MapStruct:** unmappedTargetPolicy = ERROR/WARN/IGNORE at @Mapper or @MapperConfig level +**Status: ✅ full** · **MapStruct:** unmappedTargetPolicy = ERROR/WARN/IGNORE at @Mapper or @MapperConfig level **telescope:** @@ -774,10 +780,10 @@ codegen/src/main/java/io/github/eschizoid/telescope/codegen/MapperVerifierProces Diagnostic.Kind.ERROR/WARNING/off), 325-328 (unmatchedTargets/unmatchedSources reported); docs/adr/0012-compile-time-mapper-verification.md:1-40 -### ⚠️ Null value strategies +### Null value strategies -**MapStruct:** nullValueMappingStrategy / nullValuePropertyMappingStrategy (RETURN_NULL vs RETURN_DEFAULT, SET_TO_NULL -vs IGNORE on update) +**Status: ⚠️ partial** · **MapStruct:** nullValueMappingStrategy / nullValuePropertyMappingStrategy (RETURN_NULL vs +RETURN_DEFAULT, SET_TO_NULL vs IGNORE on update) **telescope:** @@ -821,9 +827,10 @@ partial ... leaving the rest of base untouched'); core/src/test/java/io/github/eschizoid/telescope/NullStrategyTest.java:25-100 and tail (PROPAGATE default, DEFAULT substitution for String/wrappers/BigDecimal/collections, bean targets, backward-unchanged) -### ⚠️ Null check strategy +### Null check strategy -**MapStruct:** nullValueCheckStrategy = ALWAYS — guard every property read before conversion/setter +**Status: ⚠️ partial** · **MapStruct:** nullValueCheckStrategy = ALWAYS — guard every property read before +conversion/setter **telescope:** @@ -855,9 +862,9 @@ internal/src/main/java/io/github/eschizoid/telescope/internal/optics/Iso.java:14 core/src/main/java/io/github/eschizoid/telescope/mapping/Mapping.java:269-273 + 314-317 (toOrElse/toOrElseGet null-short-circuit before user predicates fire) -### ⚠️ Conditional mapping +### Conditional mapping -**MapStruct:** @Condition presence-check methods (e.g. only map non-blank strings) +**Status: ⚠️ partial** · **MapStruct:** @Condition presence-check methods (e.g. only map non-blank strings) **telescope:** @@ -896,10 +903,10 @@ core/src/test/java/io/github/eschizoid/telescope/MappingOrElseTest.java (7 @Test ## Advanced -### ⚠️ Config inheritance (@InheritConfiguration / @InheritInverseConfiguration) +### Config inheritance -**MapStruct:** @InheritConfiguration reuses a base @Mapping set on a sibling method; @InheritInverseConfiguration -derives the reverse method from the forward one. +**Status: ⚠️ partial** · **MapStruct:** @InheritConfiguration reuses a base @Mapping set on a sibling method; +@InheritInverseConfiguration derives the reverse method from the forward one. **telescope:** @@ -930,10 +937,10 @@ core/src/test/java/io/github/eschizoid/telescope/MapperBuilderTest.java:70-87 (s different mappers); core/src/main/java/io/github/eschizoid/telescope/conversion/Mapper.java:330 (forward), :426-433 (backward from the same Iso); README.md:442 ("Same Mapping.to(...) row works both ways") -### ✅ Decorators (@DecoratedWith) +### Decorators -**MapStruct:** @DecoratedWith(CustomDecorator.class) — an abstract decorator class wraps the generated mapper, -overriding selected methods and delegating to the injected original. +**Status: ✅ full** · **MapStruct:** @DecoratedWith(CustomDecorator.class) — an abstract decorator class wraps the +generated mapper, overriding selected methods and delegating to the injected original. **telescope:** @@ -961,10 +968,10 @@ afterForward, javadoc :497-502 cites @AfterMapping with @MappingTarget), :539 (b :246-252 (asTelescope carries the hook chain), :286-288 (toForwardMapper carries hooks); :151-159 (public Mapper.create(forward, backward, ...) for wholesale wrapping) -### ✅ DI component models (componentModel = spring/cdi/jakarta) +### DI component models -**MapStruct:** componentModel = "spring" / "cdi" / "jakarta" annotates the generated mapper impl so it is discovered as -an injectable bean. +**Status: ✅ full** · **MapStruct:** componentModel = "spring" / "cdi" / "jakarta" annotates the generated mapper impl +so it is discovered as an injectable bean. **telescope:** @@ -993,15 +1000,15 @@ Quarkus module's auto-collection uses ArC's @All — but a Mapper is an ordinary Evidence: spring-boot-starter/src/main/java/io/github/eschizoid/telescope/spring/TelescopeAutoConfiguration.java:34-60 -(@AutoConfiguration builds TelescopeMapperRegistry from every Mapper bean; javadoc :26-28 "declare @Bean Mapper +(@AutoConfiguration builds TelescopeMapperRegistry from every Mapper bean; javadoc :26-28 "declare @Bean `Mapper` and it shows up in the registry"); quarkus/src/main/java/io/github/eschizoid/telescope/quarkus/TelescopeProducer.java:23-42 (@ApplicationScoped producer, -ArC @All List> collector); README.md:373 (starter row in the comparison table) +ArC @All `List>` collector); README.md:373 (starter row in the comparison table) -### ⚠️ Enum mapping (@ValueMapping / @EnumMapping) +### Enum mapping -**MapStruct:** @ValueMapping(source = "X", target = "Y") per-constant renames with ANY_REMAINING/ANY_UNMAPPED defaults; -@EnumMapping name-transformation strategies (prefix/suffix/case). +**Status: ⚠️ partial** · **MapStruct:** @ValueMapping(source = "X", target = "Y") per-constant renames with +ANY_REMAINING/ANY_UNMAPPED defaults; @EnumMapping name-transformation strategies (prefix/suffix/case). **telescope:** @@ -1028,10 +1035,10 @@ Enum.valueOf both ways), :190-225 (factory-time exhaustiveness diff naming missi tgt, fwd, bwd)); core/src/test/java/io/github/eschizoid/telescope/MappingEnumToTest.java:62 (happy-path round-trip), :91-113 (mismatch diagnostics + escape-hatch message); README.md:369, :988 (comparison rows) -### ⚠️ Subclass mapping (@SubclassMapping) +### Subclass mapping -**MapStruct:** @SubclassMapping(source = Sub.class, target = SubDto.class) — polymorphic dispatch over an -abstract/sealed source hierarchy, inheriting the parent method's mapping config. +**Status: ⚠️ partial** · **MapStruct:** @SubclassMapping(source = Sub.class, target = SubDto.class) — polymorphic +dispatch over an abstract/sealed source hierarchy, inheriting the parent method's mapping config. **telescope:** From 81b3b43a4a71df302222545f60a728a53e29f093 Mon Sep 17 00:00:00 2001 From: mariano Date: Wed, 8 Jul 2026 23:40:45 -0500 Subject: [PATCH 3/6] =?UTF-8?q?docs:=20Copilot=20round=20=E2=80=94=20parse?= =?UTF-8?q?able=20snippets,=20copy-pasteable=20import,=20self-consistent?= =?UTF-8?q?=20evidence?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - The migration guide's side-by-side opener is now two valid java fences (MapStruct interface vs telescope value) instead of one unparseable mixed block. - The verification snippet imports OpticNode.Mapped so it compiles as pasted. - The @ObjectFactory evidence note no longer cites a repo-wide grep that this document itself now invalidates. --- docs/mapstruct-migration.md | 26 ++++++++++++++++++++------ docs/mapstruct-parity.md | 4 ++-- 2 files changed, 22 insertions(+), 8 deletions(-) diff --git a/docs/mapstruct-migration.md b/docs/mapstruct-migration.md index f80d3a9b..358d2150 100644 --- a/docs/mapstruct-migration.md +++ b/docs/mapstruct-migration.md @@ -11,16 +11,28 @@ A MapStruct mapper is an annotated interface whose implementation is generated a **value** you build and hold: ```java -// MapStruct // telescope -@Mapper import static io.github.eschizoid.telescope.mapping.Mapping.to; +// MapStruct — an annotated interface, implementation generated at compile time +@Mapper public interface CustomerMapper { - @Mapping(source = "email", Mapper MAPPER = Telescope.mapper( - target = "contactEmail") Customer.class, CustomerDto.class, - CustomerDto toDto(Customer customer); to(Customer::email, CustomerDto::getContactEmail)); - Customer fromDto(CustomerDto dto); // backward() is the same value — no second method + @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 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. @@ -64,6 +76,8 @@ Every mapping row above is expanded — with evidence and limitations — in the 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(); diff --git a/docs/mapstruct-parity.md b/docs/mapstruct-parity.md index 24d4539a..27e71c47 100644 --- a/docs/mapstruct-parity.md +++ b/docs/mapstruct-parity.md @@ -663,8 +663,8 @@ losing auto-mapping for the remaining fields. accept only the 4-value WriteStrategy enum at :58-63 — no Supplier/factory function overload); core/src/main/java/io/github/eschizoid/telescope/conversion/MapperBuilder.java:91-160 (create/inherit/add/build — no factory hook); core/src/main/java/io/github/eschizoid/telescope/Telescope.java:463-495 (from/to/using escape hatch); -conversion/Mapper.java:391-423 (into() covers the load-existing-instance use case); grep for 'ObjectFactory' across -_.java/_.md returned zero hits +conversion/Mapper.java:391-423 (into() covers the load-existing-instance use case); no ObjectFactory counterpart exists +anywhere in the main source sets ### Builder support From 6a11b3d2fcd052fd9a3b55ebe35dc2e9b5daeff3 Mon Sep 17 00:00:00 2001 From: mariano Date: Thu, 9 Jul 2026 06:30:19 -0500 Subject: [PATCH 4/6] =?UTF-8?q?docs:=20Copilot=20round=20two=20=E2=80=94?= =?UTF-8?q?=20consistent=20citations,=20unambiguous=20conversion=20row,=20?= =?UTF-8?q?harmonized=20into()=20javadoc?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Parity-matrix evidence citations now carry the full module path everywhere (four bare module-relative cites expanded). - The migration table's type-conversion row states its direction (source String -> target LocalDate: forward parses, backward formats) so the function order can't be misread. - Mapper.into's equivalence paragraph no longer contradicts the silent-skip setter note — 'each property ... is written' with the skip cross-reference. --- .../telescope/conversion/Mapper.java | 9 ++--- docs/mapstruct-migration.md | 34 +++++++++---------- docs/mapstruct-parity.md | 34 ++++++++++--------- 3 files changed, 40 insertions(+), 37 deletions(-) diff --git a/core/src/main/java/io/github/eschizoid/telescope/conversion/Mapper.java b/core/src/main/java/io/github/eschizoid/telescope/conversion/Mapper.java index 745b1325..65a238f2 100644 --- a/core/src/main/java/io/github/eschizoid/telescope/conversion/Mapper.java +++ b/core/src/main/java/io/github/eschizoid/telescope/conversion/Mapper.java @@ -363,10 +363,11 @@ public B forward(final A a) { * repository.save(managed); * } * - *

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. + *

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. * *

Records rejected. Records are immutable; calling {@code into(...)} on a record-target * mapper throws {@link UnsupportedOperationException} at apply time. Use {@link #forward(Object)} diff --git a/docs/mapstruct-migration.md b/docs/mapstruct-migration.md index 358d2150..38c02ad0 100644 --- a/docs/mapstruct-migration.md +++ b/docs/mapstruct-migration.md @@ -51,23 +51,23 @@ 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 (`String` ↔ `LocalDate`, …) | `to(A::birthDate, B::getBirthDate, LocalDate::parse, LocalDate::toString)` | -| 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; add `-Atelescope.verify=warn` for the build-time warning) | -| `componentModel = "spring"/"cdi"` | plain `@Bean` / `@Produces Mapper` + the starter's `TelescopeMapperRegistry` | -| generated `…MapperImpl` you read to debug | `mapper.explain()` / `mapper.trace(input)` / flip a log level — [introspection](../README.md) | +| 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; add `-Atelescope.verify=warn` for the build-time warning) | +| `componentModel = "spring"/"cdi"` | plain `@Bean` / `@Produces Mapper` + 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). diff --git a/docs/mapstruct-parity.md b/docs/mapstruct-parity.md index 27e71c47..d0d0afca 100644 --- a/docs/mapstruct-parity.md +++ b/docs/mapstruct-parity.md @@ -311,8 +311,9 @@ strict-null + predicate-gated), Mapping.java:276-318 (toOrElseGet lazy + predica core/src/test/java/io/github/eschizoid/telescope/MappingConstantComputeTest.java:46-101 (constant stamps literal; backward drops to type default), core/src/test/java/io/github/eschizoid/telescope/MappingOrElseTest.java:27-155 (null->default, pass-through, lazy supplier, empty-string/empty-collection predicates); -core/src/main/java/io/github/eschizoid/telescope/annotations/Constant.java:41-50 and annotations/Default.java:48-61 -(codegen literals, parsed at emit time against field type); README.md:986-990 +core/src/main/java/io/github/eschizoid/telescope/annotations/Constant.java:41-50 and +core/src/main/java/io/github/eschizoid/telescope/annotations/Default.java:48-61 (codegen literals, parsed at emit time +against field type); README.md:986-990 ## Collections & containers @@ -663,8 +664,8 @@ losing auto-mapping for the remaining fields. accept only the 4-value WriteStrategy enum at :58-63 — no Supplier/factory function overload); core/src/main/java/io/github/eschizoid/telescope/conversion/MapperBuilder.java:91-160 (create/inherit/add/build — no factory hook); core/src/main/java/io/github/eschizoid/telescope/Telescope.java:463-495 (from/to/using escape hatch); -conversion/Mapper.java:391-423 (into() covers the load-existing-instance use case); no ObjectFactory counterpart exists -anywhere in the main source sets +core/src/main/java/io/github/eschizoid/telescope/conversion/Mapper.java:391-423 (into() covers the +load-existing-instance use case); no ObjectFactory counterpart exists anywhere in the main source sets ### Builder support @@ -695,12 +696,12 @@ withX) covers Lombok, Immutables-fluent, and JavaBean-style builders. Evidence: internal/src/main/java/io/github/eschizoid/telescope/internal/Beans.java:742-768 (autoWriter probe order: setters → static builder() at :767 → fields → all-args ctor), :720-726 (builderWriter factory), :1229-1232 + 1259-1329 (BuilderWriter: requires static method named exactly 'builder()' returning a type with 'build()' at :1272-1289; setter -matching by exact name / setX / withX; LMF-de-reflected dispatch); mapping/WriteHint.java:50,59 (BUILDER strategy, -'requires a static builder() method'); -lombok/src/test/java/io/github/eschizoid/telescope/codegen/lombok/fixtures/BuilderUser.java:10-12 (@Builder fixture) and -LombokFocusProcessorTest.java:53-68 (@Builder and @Value+@Builder navigators verified end-to-end); -core/src/test/java/io/github/eschizoid/telescope/DeepMappingTest.java:576-712 (writeBean BUILDER/FIELDS/CONSTRUCTOR hint -tests) +matching by exact name / setX / withX; LMF-de-reflected dispatch); +core/src/main/java/io/github/eschizoid/telescope/mapping/WriteHint.java:50,59 (BUILDER strategy, 'requires a static +builder() method'); lombok/src/test/java/io/github/eschizoid/telescope/codegen/lombok/fixtures/BuilderUser.java:10-12 +(@Builder fixture) and LombokFocusProcessorTest.java:53-68 (@Builder and @Value+@Builder navigators verified +end-to-end); core/src/test/java/io/github/eschizoid/telescope/DeepMappingTest.java:576-712 (writeBean +BUILDER/FIELDS/CONSTRUCTOR hint tests) ### Records and constructor mapping @@ -733,12 +734,13 @@ multi-constructor immutables: unsupported). Evidence: internal/src/main/java/io/github/eschizoid/telescope/internal/Records.java:273-307 (cached canonical-constructor invoker per record class, components in canonical order, MethodHandle asSpreader); -conversion/Mapper.java:301 ('Records: rebuilds via the canonical constructor'); internal/Beans.java:710-718 -(ConstructorWriter: name-matched with -parameters, positional fallback) and :769-790 (auto-detected as autoWriter's 4th -rung with -parameters + name-alignment safety check, loud error otherwise); mapping/WriteHint.java:54-55 (CONSTRUCTOR -strategy docs); core/src/test/java/io/github/eschizoid/telescope/DeepMappingTest.java:42-59 (record↔record fixtures), -:209 (nested record mapper), :671-688 ('CONSTRUCTOR hint constructs an immutable all-args-only POJO' + auto-detection -test) +core/src/main/java/io/github/eschizoid/telescope/conversion/Mapper.java:301 ('Records: rebuilds via the canonical +constructor'); internal/src/main/java/io/github/eschizoid/telescope/internal/Beans.java:710-718 (ConstructorWriter: +name-matched with -parameters, positional fallback) and :769-790 (auto-detected as autoWriter's 4th rung with +-parameters + name-alignment safety check, loud error otherwise); +core/src/main/java/io/github/eschizoid/telescope/mapping/WriteHint.java:54-55 (CONSTRUCTOR strategy docs); +core/src/test/java/io/github/eschizoid/telescope/DeepMappingTest.java:42-59 (record↔record fixtures), :209 (nested +record mapper), :671-688 ('CONSTRUCTOR hint constructs an immutable all-args-only POJO' + auto-detection test) ## Policies & null handling From c5facc00b02641d07da02bc9a4491e835a4eb300 Mon Sep 17 00:00:00 2001 From: mariano Date: Thu, 9 Jul 2026 06:42:01 -0500 Subject: [PATCH 5/6] =?UTF-8?q?docs:=20second-round=20fleet=20fixes=20?= =?UTF-8?q?=E2=80=94=20truthful=20verifier=20claim,=20typed=20patch=20snip?= =?UTF-8?q?pet,=20residual=20contradictions?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - The WARN-analog cell no longer claims -Atelescope.verify=warn warns on mapperForward sites (the verifier skips completeness on lenient factories); the real build-time-warning path is strict mapper(...) at verify=warn. - The in-place-update snippet's patch(...) call is typed correctly (patch(base A, partial B) -> A; all three positions were inverted). - MergeStep.from javadoc drops the build-time source-bag validation claim (the check happens at forward time via Sources.byClass). - Mapper.into's scoping comment no longer cites an IllegalArgumentException the silent-skip write path can't raise. - Merge's auto-mismatch error stops recommending a converter-taking from(...) overload that doesn't exist. - Expressions snippet separates compute vs toOrElseGet on the same field (the fixup would stamp over the default, leaving a dead row); traceId is typed String consistently across snippets. --- .../io/github/eschizoid/telescope/Merge.java | 2 +- .../telescope/conversion/Mapper.java | 6 ++-- .../telescope/mapping/MergeStep.java | 6 ++-- docs/mapstruct-migration.md | 34 +++++++++---------- docs/mapstruct-parity.md | 11 +++--- 5 files changed, 30 insertions(+), 29 deletions(-) diff --git a/core/src/main/java/io/github/eschizoid/telescope/Merge.java b/core/src/main/java/io/github/eschizoid/telescope/Merge.java index 69cf62f4..ba3fe3ae 100644 --- a/core/src/main/java/io/github/eschizoid/telescope/Merge.java +++ b/core/src/main/java/io/github/eschizoid/telescope/Merge.java @@ -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 reader = src -> sourceRefl.read(src, name); diff --git a/core/src/main/java/io/github/eschizoid/telescope/conversion/Mapper.java b/core/src/main/java/io/github/eschizoid/telescope/conversion/Mapper.java index 65a238f2..93f5fd8a 100644 --- a/core/src/main/java/io/github/eschizoid/telescope/conversion/Mapper.java +++ b/core/src/main/java/io/github/eschizoid/telescope/conversion/Mapper.java @@ -408,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(patchByTargetField.size()); for (final var name : patchByTargetField.keySet()) { staged.put(name, targetRefl.read(produced, name)); diff --git a/core/src/main/java/io/github/eschizoid/telescope/mapping/MergeStep.java b/core/src/main/java/io/github/eschizoid/telescope/mapping/MergeStep.java index f656cba9..b3f58a1f 100644 --- a/core/src/main/java/io/github/eschizoid/telescope/mapping/MergeStep.java +++ b/core/src/main/java/io/github/eschizoid/telescope/mapping/MergeStep.java @@ -42,9 +42,9 @@ record FromInferred(Accessor src, Accessor tgt) implements Mer record AutoSameName(Class sourceClass) implements MergeStep {} /** - * 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. * *

{@code
    * Telescope.merge(Profile.class,
diff --git a/docs/mapstruct-migration.md b/docs/mapstruct-migration.md
index 38c02ad0..22d85b45 100644
--- a/docs/mapstruct-migration.md
+++ b/docs/mapstruct-migration.md
@@ -51,23 +51,23 @@ 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; add `-Atelescope.verify=warn` for the build-time warning) |
-| `componentModel = "spring"/"cdi"`                      | plain `@Bean` / `@Produces Mapper` + the starter's `TelescopeMapperRegistry`                                                                                                  |
-| generated `…MapperImpl` you read to debug              | `mapper.explain()` / `mapper.trace(input)` / flip a log level — [introspection](../README.md)                                                                                       |
+| 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` + 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).
 
diff --git a/docs/mapstruct-parity.md b/docs/mapstruct-parity.md
index d0d0afca..40a0cc23 100644
--- a/docs/mapstruct-parity.md
+++ b/docs/mapstruct-parity.md
@@ -251,9 +251,10 @@ to @Mapping qualifiedBy); core/src/test/java/io/github/eschizoid/telescope/Migra
 Telescope.mapper(Order.class, OrderDto.class,
     to(Order::id, OrderDto::id),
     compute(OrderDto::createdAt, Instant::now),          // expression: fresh per forward call
-    compute(OrderDto::traceId,   UUID::randomUUID),
-    compute(OrderDtoTelescope.of().audit().createdAt(), Instant::now), // nested target
-    toOrElseGet(Order::createdAt, OrderDto::createdAt, Instant::now))  // defaultExpression
+    compute(OrderDto::traceId, () -> UUID.randomUUID().toString()),
+    compute(OrderDtoTelescope.of().audit().createdAt(), Instant::now)) // nested target
+  // ...or, as the defaultExpression analog (instead of compute on the same field):
+  //   toOrElseGet(Order::createdAt, OrderDto::createdAt, Instant::now)
   // source-referencing expression (MapStruct: expression = "java(s.first() + \" \" + s.last())"):
   .afterForward((src, dto) -> dto.withDisplayName(src.firstName() + " " + src.lastName()));
 // or per-field: toOneWay(Order::total, OrderDto::totalText, t -> "$" + t)
@@ -289,7 +290,7 @@ Telescope.mapper(Order.class, OrderDto.class,
     constant(OrderDtoTelescope.of().shipping().country(), "US"),        // nested target
     toOrElse(Order::region, OrderDto::region, "EMEA"),                  // @Mapping(defaultValue = ...)
     toOrElse(Order::displayName, OrderDto::displayName, "(unnamed)", String::isBlank), // predicate-gated
-    toOrElseGet(Order::traceId, OrderDto::traceId, UUID::randomUUID));  // lazy default
+    toOrElseGet(Order::traceId, OrderDto::traceId, () -> UUID.randomUUID().toString()));  // lazy default
 
 // Codegen (@Bridge) form:
 @Bridge(value = UserEntity.class,
@@ -619,7 +620,7 @@ final OrderEntity managed = repository.findById(id).orElseThrow();
 mapper.into(managed, dto);        // writes dto's mapped fields onto `managed` via setters, same reference back
 repository.save(managed);
 // sparse sibling: only non-null fields of a partial target overlay the base
-final OrderEntity updated = mapper.patch(entity, dtoPatch);
+final OrderDto updated = mapper.patch(dto, entityPatch); // patch(base A, partial B) -> A
 ```
 
 Deliberate parity feature — the javadoc names @MappingTarget explicitly and the test class pins JPA load-mutate-save

From 68953a9c3219c483fe549e828e3eaaee8fb4a5d8 Mon Sep 17 00:00:00 2001
From: mariano 
Date: Thu, 9 Jul 2026 06:47:52 -0500
Subject: [PATCH 6/6] docs: repair inline-code spans split across <-> tokens

The generics-backticking pass wrapped the type name up to '<' as its own span,
splitting String<->number style tokens into broken markdown. Each conversion
pair is now one clean code span.
---
 docs/mapstruct-parity.md | 8 ++++----
 1 file changed, 4 insertions(+), 4 deletions(-)

diff --git a/docs/mapstruct-parity.md b/docs/mapstruct-parity.md
index 40a0cc23..e5bb5b14 100644
--- a/docs/mapstruct-parity.md
+++ b/docs/mapstruct-parity.md
@@ -173,7 +173,7 @@ forward), 97-104 (arity 3), 121-130 (arity 5, single factory)
 ### Implicit type conversions
 
 **Status: ⚠️ partial** · **MapStruct:** Automatic conversions between source/target types: primitive<->wrapper
-autoboxing, `String<->`number, enum<->String, date/time<->String, widening numeric conversions — all silent, no
+autoboxing, `String<->number`, `enum<->String`, `date/time<->String`, widening numeric conversions — all silent, no
 declaration needed.
 
 **telescope:**
@@ -191,15 +191,15 @@ Telescope.mapper(Src.class, Dst.class,
     enumTo(Src::status, Dst::status, SrcStatus.class, DstStatus.class));  // enum<->enum by name, exhaustiveness-checked
 ```
 
-`Primitive<->`wrapper is genuinely automatic and matches MapStruct's null->0/false behavior (tested). But
-`String<->`number, enum<->String, and date/time conversions are deliberately NOT implicit (ADR-0002 'no fuzzy
+`Primitive<->wrapper` is genuinely automatic and matches MapStruct's null->0/false behavior (tested). But
+`String<->number`, `enum<->String`, and date/time conversions are deliberately NOT implicit (ADR-0002 'no fuzzy
 auto-mapping'): a same-name field with mismatched types throws at mapper-build time with a message naming the fix. The
 workaround is one explicit to(src, tgt, fwd, bwd) row per pair — compile-typed, but a MapStruct migrator must write rows
 MapStruct generated silently. enum<->enum by name gets first-class sugar (enumTo, with build-time exhaustiveness
 MapStruct lacks); enum<->String does not.
 
 Evidence: core/src/main/java/io/github/eschizoid/telescope/DeepMap.java:1016-1035 (Identity / PrimitiveWrapper
-autobox with JLS-default null guard / CollectionCopy branches), DeepMap.java:1049-1069 (`Optional<->`nullable lift);
+autobox with JLS-default null guard / CollectionCopy branches), DeepMap.java:1049-1069 (`Optional<->nullable` lift);
 core/src/test/java/io/github/eschizoid/telescope/MigrationRegressionTest.java:472-500 (primitive<->wrapper auto-boxed,
 null boxed -> JLS default), MigrationRegressionTest.java:2815-2845 (same-name Integer vs String pair fails fast pointing
 at 'to(src, tgt, forward, backward)'; 4-arg transform is the documented fix);