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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,24 @@ subprojects {
)
}
}

// Make every module's test run informative rather than a wall of pass/fail: show per-test
// events plus anything the tests log (through java.lang.System.Logger -> JUL console), with a
// message-only JUL format so narration reads cleanly. Single source of truth for all modules.
tasks.withType<Test>().configureEach {
testLogging {
// Gradle side: stream the forked JVM's stdout/stderr live to the console.
showStandardStreams = true
events("passed", "failed", "skipped")
exceptionFormat = org.gradle.api.tasks.testing.logging.TestExceptionFormat.FULL
}
// JUnit side: also capture each test's stdout/stderr and file it under that test in the
// XML/HTML reports, so narration is attached per-test, not only streamed to console.
systemProperty("junit.platform.output.capture.stdout", "true")
systemProperty("junit.platform.output.capture.stderr", "true")
// Message-only JUL format so System.Logger narration reads cleanly, no timestamp/source noise.
systemProperty("java.util.logging.SimpleFormatter.format", "%5\$s%n")
}
}

jreleaser {
Expand Down
51 changes: 51 additions & 0 deletions examples/mapstruct-vs-telescope/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,57 @@ copy-with-changes, or an optics library) the moment you step past mapping.

---

## Act 3 — telescope explains and traces itself; MapStruct is a black box

Acts 1 and 2 were about _writing_ the mapping. Act 3 is about **seeing it**. Every telescope mapper answers two
questions MapStruct structurally cannot: its structure lives only in generated `…MapperImpl.java` you go read, and its
runtime behaviour is whatever you hand-instrument.

**`explain()` — the static structure, as data.** The Act 1 rename isn't a string buried in generated code; it's a row
you can print or assert on:

```java
TelescopeMappings.CUSTOMER_MAPPER.explain();
// Mapped:
// ✓ email → contactEmail
// ✓ name → name
```

That `✓ email → contactEmail` is the exact override from Act 1, now a first-class correspondence. The test asserts on it
directly — `explain().mapped()` contains `("email", "contactEmail")` — a completeness check MapStruct offers no surface
for.

**`trace(input)` — the same rows with real values, whole nested graph.** For one `Order`:

```java
TelescopeMappings.ORDER_MAPPER.trace(order);
// ✓ id "o-1" → id "o-1"
// • customer Customer[name=Ada, email=ada@example.com] → customer CustomerDto[name=Ada, contactEmail=ada@example.com]
// • lines [LineItem[sku=sku-1, …], …] → lines [LineItemDto[sku=sku-1, …], …]
```

**Auto-logging — flip a level, no code change.** telescope logs its own `explain()` at `DEBUG` and every conversion's
`trace()` at `TRACE` through `java.lang.System.Logger` (java.base, zero dependency). With the JDK's default backend it
routes through `java.util.logging` (`TRACE` maps to JUL `FINER`); name the type-pair logger and every mapping narrates
itself:

```properties
# logging.properties — the zero-dependency JDK default
io.github.eschizoid.telescope.mapper.Order.OrderDto.level = FINER
```

The same facade reaches any backend through its own level syntax — Logback
(`<logger name="…mapper.Order.OrderDto" level="TRACE"/>`) or Spring Boot (`logging.level.…=TRACE`).

MapStruct's generated `OrderMapStructMapperImpl` is opaque: to see what it mapped you read generated source; to see
values at runtime you instrument it by hand. telescope makes both first-class — structure you can assert on, values you
can flip on.

> This slice's own tests prove the point: every act narrates what it proves through `System.Logger`. Run
> `./gradlew :examples:mapstruct-vs-telescope:test` and read the walkthrough, not just the green ticks.
Comment thread
eschizoid marked this conversation as resolved.

---

## Where MapStruct is still the right call

Being fair is the point of a reproducible comparison:
Expand Down
3 changes: 3 additions & 0 deletions examples/mapstruct-vs-telescope/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -37,4 +37,7 @@ dependencies {

tasks.named<Test>("test") {
useJUnitPlatform()
// The test-output plumbing (showStandardStreams + per-test events + capture + clean JUL format)
// is global — set once for every module in the root build's subprojects { } block. This slice's
// tests add the per-act narration on top of it.
}
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,19 @@ private TelescopeMappings() {}
to(Customer::email, CustomerDto::getContactEmail)
);

/**
* Act 3 — the {@code Customer -> CustomerDto} leg on its own, so its {@code explain()} renders
* the Act 1 rename as first-class data: {@code ✓ email → contactEmail}. Same {@code to(...)}
* override as {@code ORDER_MAPPER}'s nested customer hop; here it is the whole mapper, so the
* correspondence is a top-level row you can assert on. MapStruct's equivalent decision lives only
* in generated {@code OrderMapStructMapperImpl.java}.
*/
Comment thread
eschizoid marked this conversation as resolved.
public static final Mapper<Customer, CustomerDto> CUSTOMER_MAPPER = Telescope.mapper(
Customer.class,
CustomerDto.class,
to(Customer::email, CustomerDto::getContactEmail)
);

/**
* Act 2 — deep immutable update, kept as a reusable <em>path value</em> that mirrors {@code
* ORDER_MAPPER}: a path is a thing you store, not a call you re-spell. The same {@code Telescope}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,15 +1,19 @@
package io.github.eschizoid.telescope.example.mapstruct;

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotSame;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertTrue;

import io.github.eschizoid.telescope.example.mapstruct.domain.Customer;
import io.github.eschizoid.telescope.example.mapstruct.domain.LineItem;
import io.github.eschizoid.telescope.example.mapstruct.domain.Order;
import io.github.eschizoid.telescope.example.mapstruct.mapstruct.OrderMapStructMapper;
import io.github.eschizoid.telescope.example.mapstruct.mapstruct.SilentDropMapper;
import io.github.eschizoid.telescope.example.mapstruct.telescope.TelescopeMappings;
import java.lang.System.Logger;
import java.lang.System.Logger.Level;
import java.math.BigDecimal;
import java.util.List;
import org.junit.jupiter.api.DisplayName;
Expand All @@ -25,6 +29,8 @@
*/
class MapStructVsTelescopeTest {

private static final Logger LOG = System.getLogger(MapStructVsTelescopeTest.class.getName());

private static Order sampleOrder() {
return new Order(
"o-1",
Expand All @@ -48,6 +54,10 @@ void bothFrameworksMapToTheSameDto() {
"the email -> contactEmail rename landed"
);
assertEquals(2, viaTelescope.getLines().size(), "the line-item collection recursed");
log(
"Act 1 — both frameworks produce the identical OrderDto (no strawman):",
"MapStruct: " + viaMapStruct + "\ntelescope: " + viaTelescope
);
}

@Test
Expand All @@ -56,6 +66,7 @@ void telescopeMapperRoundTrips() {
final var order = sampleOrder();
final var roundTripped = TelescopeMappings.ORDER_MAPPER.backward(TelescopeMappings.ORDER_MAPPER.forward(order));
assertEquals(order, roundTripped, "one mapper(...) value gives both directions; MapStruct needs a second method");
log("Bidirectional for free — backward(forward(order)) == order:", roundTripped);
}

@Test
Expand All @@ -68,6 +79,10 @@ void mapStructSilentlyDropsUnmappedTarget() {
dto.getRegion(),
"default unmappedTargetPolicy=WARN compiles and nulls an unmapped target (a source rename is the separate case — a compile error)"
);
log(
"Unmapped-target footgun — MapStruct's default policy nulls a target with no source:",
"contactEmail = " + dto.getContactEmail() + " | region = " + dto.getRegion() + " (silently null)"
);
}

@Test
Expand All @@ -85,5 +100,40 @@ void deepUpdateRebuildsImmutably() {
order.lines().get(0).price(),
"the original Order is unchanged — immutable update"
);
log(
"Act 2 — deep immutable update rebuilds the graph, original untouched:",
"before: " + order.lines() + "\nafter: " + taxed.lines()
);
}

@Test
@DisplayName("Act 3: the telescope mapper explains and traces itself; MapStruct is a black box")
void introspectionExposesWhatTheMapperDoes() {
final var order = sampleOrder();

// Static structure — no input needed. The Act 1 rename is a first-class row here, not a string
// buried in generated OrderMapStructMapperImpl.java.
final var report = TelescopeMappings.CUSTOMER_MAPPER.explain();
assertFalse(report.isEmpty(), "the mapper describes its own structure");
assertTrue(
report
.mapped()
.stream()
.anyMatch(m -> m.from().equals("email") && m.to().equals("contactEmail")),
() -> "the email -> contactEmail rename is enumerable data, not opaque generated code:\n" + report
);
log("Act 3 — CUSTOMER_MAPPER.explain() (structure as data; MapStruct has no equivalent):", report);

// Per-conversion values — the same rows with the actual Order data filled in, whole graph deep.
final var trace = TelescopeMappings.ORDER_MAPPER.trace(order).toString();
assertTrue(trace.contains("o-1"), () -> "the trace shows the real values flowing through:\n" + trace);
log("Act 3 — ORDER_MAPPER.trace(order) (per-conversion values, nested graph and all):", trace);
}

// Narrate what each act proves when the suite runs, through java.lang.System.Logger — the same
// zero-dependency facade telescope logs its own mappings through — so `./gradlew test` reads as a
// head-to-head walkthrough rather than a wall of green ticks.
Comment thread
eschizoid marked this conversation as resolved.
private static void log(final String heading, final Object body) {
LOG.log(Level.INFO, () -> "\n" + heading + "\n" + body + "\n");
}
}
Loading