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
17 changes: 16 additions & 1 deletion core/src/main/java/io/github/eschizoid/telescope/FromMap.java
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,11 @@
import io.github.eschizoid.telescope.internal.NullDefaults;
import io.github.eschizoid.telescope.internal.Records;
import io.github.eschizoid.telescope.internal.pairing.PropertyNames;
import io.github.eschizoid.telescope.introspection.OpticNode;
import io.github.eschizoid.telescope.mapping.Extract;
import io.github.eschizoid.telescope.mapping.MapExtractStep;
import java.lang.reflect.RecordComponent;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.HashMap;
import java.util.LinkedHashMap;
Expand Down Expand Up @@ -67,7 +69,20 @@ static <T> ForwardMapper<Map<String, Object>, T> build(final Class<T> target, fi
final Function<Map<String, Object>, T> forward = target.isRecord()
? recordForward(target, byField)
: beanForward(target, byField);
return ForwardMapper.create(forward, (Class<Map<String, Object>>) (Class<?>) Map.class, target);
// The slot alignment above already decided every component's fate — surface those decisions
// as the explain() trail instead of throwing them away: one Transformed row per extract
// (map key → component, through the row's converter), one MISSING_SOURCE skip per defaulted
// slot. The report is derived from the same data the forward path runs on, so it cannot drift.
final var trail = new ArrayList<OpticNode>(known.size());
for (final var comp : known) {
final var row = byField.get(comp);
if (row != null) {
trail.add(new OpticNode.Transformed(row.key(), comp, "map value", "converted"));
} else {
trail.add(new OpticNode.Skipped(comp, OpticNode.Reason.MISSING_SOURCE));
}
}
return ForwardMapper.create(forward, (Class<Map<String, Object>>) (Class<?>) Map.class, target, trail);
}

/**
Expand Down
32 changes: 27 additions & 5 deletions core/src/main/java/io/github/eschizoid/telescope/Merge.java
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@
import io.github.eschizoid.telescope.internal.Records;
import io.github.eschizoid.telescope.internal.Reflective;
import io.github.eschizoid.telescope.internal.optics.Getter;
import io.github.eschizoid.telescope.internal.pairing.PropertyNames;
import io.github.eschizoid.telescope.introspection.OpticNode;
import io.github.eschizoid.telescope.mapping.MergeStep;
import java.util.ArrayList;
import java.util.HashMap;
Expand Down Expand Up @@ -66,9 +68,22 @@ static <T> Mapper<Sources, T> build(final Class<T> target, final MergeStep<T>[]
);
};

// A null patch table signals "patch unsupported" — Mapper.patch then throws the same
// Surface the build-time slot decisions as the explain() trail: one Mapped row per bound
// component (SourceClass.field → target field), one MISSING_SOURCE skip per unbound one.
// Derived from the same plan the forward path runs on, so the report cannot drift.
final var names = targetRefl.names(target);
final var trail = new ArrayList<OpticNode>(names.length);
for (final var name : names) {
final var r = planByTgt.get(name);
if (r != null) {
trail.add(new OpticNode.Mapped(r.sourceClass().getSimpleName() + "." + r.srcName(), r.tgtName()));
} else {
trail.add(new OpticNode.Skipped(name, OpticNode.Reason.MISSING_SOURCE));
}
}
// A null patch table signals "patch unsupported" — Mapper.patch/into then throw the same
// UnsupportedOperationException shape backward does, instead of silently no-op-ing.
return Mapper.create(forward, backward, Sources.class, target, null);
return Mapper.create(forward, backward, Sources.class, target, null, trail);
}

/**
Expand Down Expand Up @@ -187,7 +202,14 @@ private static void resolveStep(
"."
);
claimTarget(tgtName, index, claimedTgt);
out.add(new ResolvedStep(srcClass, srcAccessor, tgtName));
out.add(
new ResolvedStep(
srcClass,
srcAccessor,
PropertyNames.property(LambdaIntrospection.methodNameOf(r.src())),
tgtName
)
);
return;
}
throw new IllegalStateException("unreachable: MergeStep is sealed");
Expand Down Expand Up @@ -231,7 +253,7 @@ private static void resolveAutoBackfill(
);
claimedTgt.add(name);
final Getter<Object, Object> reader = src -> sourceRefl.read(src, name);
out.add(new ResolvedStep(sourceClass, reader, name));
out.add(new ResolvedStep(sourceClass, reader, name, name));
}
}

Expand Down Expand Up @@ -273,5 +295,5 @@ private static Getter<Object, Object> asGetter(final Telescope.Accessor accessor
// ResolvedStep holds the row's per-call dispatch triple. `srcAccessor` is typed as the lattice's
// `Getter<Object, Object>` rather than a raw `Function` — see CLAUDE.md's "Lattice-first" rule.
// `sourceClass` keys into Sources.byClass at forward time.
record ResolvedStep(Class<?> sourceClass, Getter<Object, Object> srcAccessor, String tgtName) {}
record ResolvedStep(Class<?> sourceClass, Getter<Object, Object> srcAccessor, String srcName, String tgtName) {}
}
14 changes: 12 additions & 2 deletions core/src/main/java/io/github/eschizoid/telescope/Telescope.java
Original file line number Diff line number Diff line change
Expand Up @@ -454,11 +454,21 @@ public static <S, X> OptionalTelescope<S, X> asOptional(final Telescope<S, Optio
@SafeVarargs
@SuppressWarnings("varargs")
public static <S> Telescope<S, S> all(final Edit<S>... edits) {
// The normalizer's explain() trail is the concatenation of its edits' trails, in edit order —
// each edit's path already carries its full hop description; throwing that away left every
// multi-edit product explaining as empty.
final var trail = new ArrayList<OpticNode>();
for (final var e : edits) {
if (e instanceof EditImpl<S, ?> impl) trail.addAll(impl.path().trail);
}
final var joined = Collections.unmodifiableList(trail);
final var fused = Fusion.fuse(edits);
if (fused != null) return new Telescope<>(Iso.identity(), RecordFieldOptics.INSTANCE, fused);
if (fused != null) {
return new Telescope<>(Iso.identity(), RecordFieldOptics.INSTANCE, fused, null, joined, null);
}
Function<S, S> fold = Function.identity();
for (final var e : edits) fold = fold.andThen(e::apply);
return new Telescope<>(Iso.identity(), RecordFieldOptics.INSTANCE, fold);
return new Telescope<>(Iso.identity(), RecordFieldOptics.INSTANCE, fold, null, joined, null);
}

/**
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
package io.github.eschizoid.telescope;

import static io.github.eschizoid.telescope.Edit.over;
import static io.github.eschizoid.telescope.mapping.MapExtractStep.extract;
import static io.github.eschizoid.telescope.mapping.MergeStep.auto;
import static io.github.eschizoid.telescope.mapping.MergeStep.from;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;

import io.github.eschizoid.telescope.introspection.OpticNode;
import java.util.ArrayList;
import java.util.List;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;

/**
* Pins that every engine — not just the deep mapper — surfaces its build-time decisions through
* {@code explain()}. Before this, fromMap, merge, and every multi-edit normalizer explained as
* empty while the deep mapper promised "the report cannot drift from what the mapper does".
*/
class EngineExplainTest {

record Payment(String id, String currency, int retries) {}

record Customer(String id, String email) {}

record Audit(String createdBy, String createdAt) {}

record Profile(String id, String email, String createdBy, String note) {}

@Nested
@DisplayName("fromMap explains its slot decisions")
class FromMapExplain {

@Test
@DisplayName("one Transformed row per extract, one MISSING_SOURCE skip per defaulted slot")
void slotDecisionsSurface() {
final var mapper = Telescope.fromMap(
Payment.class,
extract("payment_id", Payment::id, Object::toString),
extract("ccy", Payment::currency, Object::toString)
);

final var report = mapper.explain();
assertFalse(report.isEmpty(), "fromMap must explain itself");
assertEquals(
List.of(new OpticNode.Transformed("payment_id", "id", "map value", "converted")),
report
.transformations()
.stream()
.filter(t -> t.to().equals("id"))
.toList()
);
assertTrue(
report.skipped().contains(new OpticNode.Skipped("retries", OpticNode.Reason.MISSING_SOURCE)),
"the defaulted slot is reported"
);
}
}

@Nested
@DisplayName("merge explains its plan")
class MergeExplain {

@Test
@DisplayName("one Mapped row per bound component (SourceClass.field → target), one skip per unbound")
void planSurfaces() {
final var mapper = Telescope.merge(
Profile.class,
from(Customer::id, Profile::id),
auto(Customer.class),
from(Audit::createdBy, Profile::createdBy)
);

final var report = mapper.explain();
assertFalse(report.isEmpty(), "merge must explain itself");
assertTrue(report.mapped().contains(new OpticNode.Mapped("Customer.id", "id")), report.mapped().toString());
assertTrue(report.mapped().contains(new OpticNode.Mapped("Customer.email", "email")));
assertTrue(report.mapped().contains(new OpticNode.Mapped("Audit.createdBy", "createdBy")));
assertTrue(report.skipped().contains(new OpticNode.Skipped("note", OpticNode.Reason.MISSING_SOURCE)));
}
}

@Nested
@DisplayName("multi-edit normalizers explain their edits")
class AllExplain {

record User(String name, String email) {}

record Team(String label, List<User> users) {}

@Test
@DisplayName("the product's trail is the concatenation of the edits' trails, in edit order")
void editsTrailsConcatenate() {
final var names = Telescope.of(Team.class).each(Team::users).field(User::name);
final var label = Telescope.of(Team.class).field(Team::label);
final var product = Telescope.all(over(names, String::toUpperCase), over(label, String::trim));

final var expected = new ArrayList<OpticNode>();
expected.addAll(names.explain().nodes());
expected.addAll(label.explain().nodes());
assertEquals(expected, product.explain().nodes());
}
}
}
Loading