Repository navigation
Capability-based builder contract for referenced builders #301
Description
What this is about
When a generated builder has a field whose type also has a builder, simple-builders can emit nested builder helpers (e.g. address(a -> a.city("X")) instead of address(new Address(...))). To do that, the processor must locate and use the referenced builder class for the field type.
Current state
BuilderScopeResolver resolves a candidate builder named <T><builderUsageSuffix> in the referenced type's package and checks a fixed, all-or-nothing contract:
- a constructor accepting the referenced type
T - a parameterless
build()method returningT
If both exist, all nested-builder helpers are generated for that field. If either is missing, none are — the field silently falls back to a plain setter.
Problems with this
-
Latent compile break. The contract does not match what the generated code actually calls. The nested-builder consumer emits:
FooBuilder b = this.foo.isSet() ? new FooBuilder(this.foo.value()) // needs ctor(T) : new FooBuilder(); // needs ctor() — NOT checked today!
A hand-written builder with
FooBuilder(T)+build()but no no-arg constructor passes the contract and produces uncompilable generated code. -
No factory-method support. Generated builders expose a static
create(), but the contract only recognizes constructors, and generated code always instantiates vianew. -
All-or-nothing granularity. One missing member disables every helper, even those the builder could satisfy.
Sub-tasks
- #308 — Extend the minimal contract to require the empty constructor (breaking: fixes the latent compile break; builders lacking
B()degrade to plain setters) - #309 — Support static factory methods for referenced builder creation (
create(), andcreate(T)/of(T)preferred over the copy ctor) - #310 — Seed referenced builders via field functions when no copy function exists (empty-builder path + one setter call per readable property, emitted as a block lambda; only used when every property is covered — partial coverage still degrades to plain setters with a debug log; also makes FreeBuilder/AutoValue shapes resolve)
- #313 — Visibility-aware contract checks (verify
ctor(T)/B()/build()/factories are accessible from the generated builder's package, not just present) - #317 — Find builders anchored inside the referenced type (MapStruct-style
T.builder()) as a second lookup location next to the same-package candidate - #319 — Detect the build function by signature, not by name (any public non-static no-arg method returning
T;java.lang.Objectmethods excluded to avoid theStringBuilder/Stringfalse positive)
Reference: MapStruct's DefaultBuilderProvider
MapStruct detects builders structurally: a public static parameterless method on the target type (e.g. Person.builder()) returning a builder B, where B has a parameterless build method (named build, configurable via @Builder(buildMethod=...)). Instantiation always goes through the static factory, never new. Ambiguous factories produce a warning and no builder; java.*/javax.* are excluded; a BuilderProvider SPI allows custom detection.
Difference to keep in mind: we anchor on a naming convention (<T><suffix> in T's package), MapStruct anchors on a factory method on T itself.
Out of scope / open questions
- Whether to also accept a
builder()/create()marker method on the referenced type as a builder anchor (MapStruct-style). builderUsagePackagessemantics stay as they are.
(Spun off from the discussion on #296 — not part of the @SimpleBuilderFor feature.)
Activity