Skip to content

Add update helpers xyzUpdate(UnaryOperator<T>) (#223) - #31

Closed
devin-ai-integration[bot] wants to merge 8 commits into
mainfrom
devin/1788618022-mapper-helpers-223
Closed

devin-ai-integration[bot] wants to merge 8 commits into
mainfrom
devin/1788618022-mapper-helpers-223

Conversation

@devin-ai-integration

@devin-ai-integration devin-ai-integration Bot commented Sep 5, 2026 •

Copy link
Copy Markdown

Summary

Implements java-helpers#223: a generateUpdateHelpers option (default ENABLED; DISABLED in @SimpleMinimalBuilder) that adds one transform method per field:

public PersonDtoBuilder nameUpdate(UnaryOperator<String> nameUpdater) {
  if (!this.name.isSet()) {
    throw new IllegalStateException("Cannot update 'name' before it is set");
  }
  this.name = TrackedValue.changedValue(nameUpdater.apply(this.name.value()));
  return this;
}
  • New UpdateHelperGenerator (priority 59, registered via the Generator service file) so it participates in the registry and deactivateGenerationComponents=UpdateHelperGenerator filtering. Applies to every field; primitives get boxed operator types (UnaryOperator<Integer>) exactly like the existing Supplier<T> setters.
  • Method name is the plain setter name + Update (MethodGeneratorUtil.generateBuilderMethodName(field) + "Update"), so setterSuffix=with yields withName(...) / withNameUpdate(...). The suffix form was chosen over mapX/updateX so the helper sorts next to its setter in IDE completion; an overload name(UnaryOperator) is not possible because implicitly typed lambdas would be ambiguous with the existing Consumer overloads.
  • Unset field → IllegalStateException (fail-fast as decided in the issue). Values copied by the With interface are initialValue(...) and therefore count as set, so instance.with(b -> b.nameUpdate(String::toUpperCase)) works.
  • null results: the helper stores the operator's result as-is, exactly like Supplier setters; build()'s existing non-null validation then rejects null for required (non-nullable/primitive) fields with "Field '…' is marked as non-null but null value was provided", while nullable fields accept it. No new generator code — covered by tests and documented in CONFIGURATION.md.
  • Generated Javadoc explains the purpose (in-place adjustment relative to the current value, With flow) plus @throws IllegalStateException for the unset case. Examples are chosen in UpdateHelperGenerator.getUpdateExample(TypeName): String::trim for strings, Math::abs for numeric primitives/wrappers, UnaryOperator.identity() otherwise.
  • Name collisions: the helper has PRIORITY_LOW, so for a DTO with fields String test and UnaryOperator<String> testUpdate the existing BuilderDefinitionCreator.resolveMethodConflicts keeps the plain testUpdate(UnaryOperator<String>) setter, drops the helper for test and logs a "Method conflict resolved" warning; testUpdateUpdate(...) for the testUpdate field is still generated. Different types (String testUpdate) produce both overloads.
  • Plumbing mirrors generateStringFormatHelpers: CompilerArgumentsEnum, SimpleBuilder.Options, BuilderConfiguration, BuilderConfigurationReader, CompilerArgumentsReader.
  • Docs: docs/CONFIGURATION.md (Helper Methods section, minimal template, options list, complete example), docs/CUSTOMIZING.md generator table.
  • Since the option is on by default, the committed example builders are regenerated (8 files); CustomerDtoBuilder (minimal builder) is unchanged. Existing exact-output tests run with the option enabled; their expected outputs include the update methods.

Tests (UpdateHelperGeneratorTest, 16 tests): on by default, -A/annotation forms incl. DISABLED, transform on set values (" bob " → "BOB", 10 → 20), throw on unset, With copy-and-modify, component deactivation, both collision scenarios, null result on @NotNull String / primitive int (build() throws, helper call itself doesn't) and on a nullable field (builds with null). BuilderProcessorTest generator count 14 → 15. Full processor suite: 435 tests green.

Link to Devin session: https://app.devin.ai/sessions/d691231fba0842a4b842b275ca4b35ed
Open in Devin Desktop: https://app.devin.ai/desktop/session/d691231fba0842a4b842b275ca4b35ed?variant=devin
Requested by: @AndreasIgel

Co-Authored-By: Andreas Igel <andreas.igel@computacenter.com>
@devin-ai-integration

Copy link
Copy Markdown
Author

🤖 Devin AI Engineer

I'll be helping with this pull request! Here's what you should know:

✅ I will automatically:

  • Address comments on this PR. Add '(aside)' to your comment to have me ignore it.
  • Look at CI failures and help fix them

Note: I can only respond to comments from users who have write access to this repository.

⚙️ Control Options:

  • Disable automatic comment, CI, and merge conflict monitoring

Co-Authored-By: Andreas Igel <andreas.igel@computacenter.com>
@devin-ai-integration devin-ai-integration Bot changed the title Add opt-in mapper helpers mapX(UnaryOperator<T>) (#223) Add mapper helpers mapX(UnaryOperator<T>) (#223) Sep 5, 2026
…rs#223)

Co-Authored-By: Andreas Igel <andreas.igel@computacenter.com>
@AndreasIgel

Copy link
Copy Markdown
Owner

Please have a deep thought of naming. Starting all with map, is this intuitive? We would have for field example all functions be named example, and then a new function with mapExample? And is it a mapping? It is more an inside applying!? So please think of these questions! And check other solutions, how this is done there. Maybe having a different naming or should we remain with map?

"[DEBUG] Starting BuilderProcessor...",
"[DEBUG] Loaded global configuration from compiler arguments: BuilderConfiguration[]",
"[DEBUG] Loaded global configuration from compiler arguments: "
+ "BuilderConfiguration[generateMapperHelpers=DISABLED]",

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why do we have this extra logging here? Does it log extra or did you change the design of this test and it needs to be activated and the asserts changed?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This only changed because the test had disabled the mapper option; that option is removed again in 1938991 and the assertion is back to BuilderConfiguration[].

Comment on lines +337 to +338
* The field must already be set; otherwise the generated method throws {@link
* IllegalStateException}.

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This should be in throws field of JavaDoc.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Removed the sentence from the option Javadoc (1938991) — the unset-case is documented once, in the generated method's @throws IllegalStateException.

Comment on lines +130 to +131
* value must have been set before (directly or via an existing instance). A <code>null</code> result is stored as-is
* and validated by <code>build()</code> like any other value.

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thus topic regarding null might not be in javadoc of all methods. Especially because it is in @throws explained

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Shortened (1938991): the null sentence is gone from the generated Javadoc (the behaviour is the same as for every other setter, so it is documented once in CONFIGURATION.md only); the unset requirement stays in the @throws.

Comment on lines +116 to +123
"Transforms the current value of <code>%s</code> in place by applying the given "
+ "operator, instead of reading it out, changing it and setting it again.\n"
+ "Useful for adjustments relative to the current value, e.g. trimming, "
+ "upper-casing, clamping or incrementing, and in combination with the "
+ "<code>With</code> copy-and-modify flow.\n"
+ "The value must have been set before (directly or via an existing instance).\n"
+ "A <code>null</code> result is stored as-is and validated by <code>build()</code> "
+ "like any other value.",

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please use textblock

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done — text block (1938991).

"if <code>%s</code> has not been set yet".formatted(originalFieldName)));

String mapperExample =
JavadocExampleValues.getMapperExample(field.getFieldType()).orElse("value -> value");

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does it make sense to have this in examples class. Could that really be reused? Or should we have the mapping and seamless not humere in the generator?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed, it was not reusable. The selection now lives in MapperHelperGenerator as a private getMapperExample(TypeName); JavadocExampleValues keeps only the generic example values it had before (1938991).

@SimpleBuilder(options = @SimpleBuilder.Options(generateJavaDoc = OptionState.DISABLED))
@SimpleBuilder(options = @SimpleBuilder.Options(
generateJavaDoc = OptionState.DISABLED,
generateMapperHelpers = OptionState.DISABLED))

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same her!

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Removed (1938991); the test only disables generateJavaDoc again.

void configurationMerge_Chain_ShouldApplyInOrder() {
// Layer 1: Defaults
BuilderConfiguration defaults = BuilderConfiguration.DEFAULT;
assertEquals(OptionState.ENABLED, defaults.generateMapperHelpers());

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why do we have here an new assert just for that? This is done different from the others right?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Removed (1938991).

.withOptions("-Asimplebuilder.formattingMode=lightweight")
.withOptions(
"-Asimplebuilder.formattingMode=lightweight",
"-Asimplebuilder.generateMapperHelpers=DISABLED")

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same here

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Removed; expected outputs in FormattingModeTest include the mapper methods now (1938991).

Compilation compilation = ProcessorTestUtils.createCompiler().compile(sourceFile);
Compilation compilation =
ProcessorTestUtils.createCompiler()
.withOptions("-Asimplebuilder.generateMapperHelpers=DISABLED")

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same here

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Removed (1938991).

Compilation compilation = createCompiler().compile(sources);
Compilation compilation =
createCompiler()
.withOptions("-Asimplebuilder.generateMapperHelpers=DISABLED")

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same here

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Removed; NullConstraintTest runs with mapper helpers enabled (1938991).

@devin-ai-integration

Copy link
Copy Markdown
Author

Please have a deep thought of naming. Starting all with map, is this intuitive? … And is it a mapping? It is more an inside applying!? … check other solutions, how this is done there.

I looked at how others do it before answering.

Why not an overload name(UnaryOperator<String>), like every other variant here (name(Supplier), name(Consumer<StringBuilder>))?
That would be the most consistent with this project, but it does not compile cleanly for users: Consumer<StringBuilder> / Consumer<XyzBuilder> and UnaryOperator<T> are all one-argument functional interfaces, so an implicitly typed lambda name(s -> s.trim()) becomes ambiguous (JLS 15.12.2.1 only uses lambda arity to prune candidates). Users would have to write name((String s) -> s.trim()). The same clash exists for every field with a nested-builder consumer. So a distinct method name is needed, and a prefix is the only idiomatic Java form (nameMap/nameMapped suffixes read badly).

What do others use?

  • FreeBuilder — the closest comparable Java builder generator — generates exactly this method and calls it mapFoo(UnaryOperator<T>): "Replaces the current property value with the result of invoking the unary operator on it … Throws an IllegalStateException if the property value has not yet been set" (https://github.com/inferred/FreeBuilder#builder-methods, e.g. person.toBuilder().mapAge(age -> age + 1)). Same semantics as here, including the unset-check. It uses mutateFoo(Consumer<Collection>) for in-place collection changes.
  • JDK: AtomicReference.updateAndGet(UnaryOperator), List.replaceAll(UnaryOperator), Map.computeIfPresent, Optional.map. "map" is the established word for value → f(value); "update"/"replace" appear when the emphasis is on the mutable holder.
  • Lombok, Immutables, AutoValue, Record-Builder: no such method. Kotlin's apply means "run a block on the receiver for side effects", and Function.apply means "invoke", so applyName(...) would suggest something different to Java readers.

Is it a mapping or "inside applying"? The field holds v, afterwards it holds f(v); nothing is changed inside the value (the operator may return a completely new object, e.g. String::trim). That is the FP definition of map, which is why FreeBuilder and the JDK use it.

IDE grouping: agreed, mapName sorts away from name(...); every prefix pays that price (FreeBuilder too), and the overload option is ruled out above.

Recommendation: keep mapX — strongest precedent with identical semantics, JDK vocabulary. If you would rather have a different verb, updateX(UnaryOperator<T>) (JDK updateAndGet) is the best alternative; it is a one-line change in MapperHelperGenerator plus doc/test renames — just say which and I'll switch.

devin-ai-integration Bot and others added 3 commits September 6, 2026 08:22
Co-Authored-By: Andreas Igel <andreas.igel@computacenter.com>
Co-Authored-By: Andreas Igel <andreas.igel@computacenter.com>
Co-Authored-By: Andreas Igel <andreas.igel@computacenter.com>
@devin-ai-integration devin-ai-integration Bot changed the title Add mapper helpers mapX(UnaryOperator<T>) (#223) Add update helpers xyzUpdate(UnaryOperator<T>) (#223) Sep 6, 2026
@devin-ai-integration

Copy link
Copy Markdown
Author

Renamed per decision: generated method is now xyzUpdate(UnaryOperator<T>) (setter name + Update, so it follows setterSuffix), option generateUpdateHelpers, generator UpdateHelperGenerator, parameter xyzUpdater, message "Cannot update 'x' before it is set"; docs, tests and example builders updated (179ae6f).

Co-Authored-By: Andreas Igel <andreas.igel@computacenter.com>
* .name(() -> "example value")
* .nameUpdate(String::trim)
* .name(sb -> sb.append("text"))
* .sponsorenUpdate(UnaryOperator.identity())

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is wrong, right? A call like this is not possible!?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Correct — the call compiles, but at that position in the chain it would throw IllegalStateException because sponsoren hadn't been set yet (the class-level example is emitted in generator-priority order, and UpdateHelperGenerator sat at 59, above the consumer generators). Fixed in 1e1ce3a by lowering the generator priority to 20 (below every setter variant), so xUpdate(...) now always appears after all setters of that field: .sponsoren(t -> ...) then .sponsorenUpdate(UnaryOperator.identity()). Example builders regenerated, test expectations updated.

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

But should it not be TargetType::identity instead?

* .pagesUpdate(Math::abs)
* .tags(List.of("example value"))
* .tags(() -> List.of("example value"))
* .tagsUpdate(UnaryOperator.identity())

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does it work with List? Or are lists not modifiable in inner tracked code?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, it works with List (and any other type): the helper hands the operator the exact reference currently stored in the TrackedValue and stores whatever the operator returns — the builder doesn't wrap or copy collections. So the operator can return a new list (l -> { var c = new ArrayList<>(l); c.add("b"); return c; }), or mutate and return the same instance if the stored list is mutable (as it is after the tags(t -> t.add(...)) ArrayListBuilder path; a List.of(...) passed via the plain setter is immutable, so there a new list must be returned). Added two runtime tests in 1e1ce3a covering tagsUpdate after the direct setter and after the consumer path.

@AndreasIgel AndreasIgel Sep 6, 2026 •

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

But is not the listbuilder producing unmodifiable lists? And how is the setter of sets and collections working? Is it taking over a list from external? Should it then not be a new list which takes the elements? How does that behave by with-Interface? Would a change there in list of copy of object, lead to a change of the list of origin? Thinking of that, this might be even the general behavior when having dtos there in properties!? Maybe this is then a totally different issue to handle this!?

)

Co-Authored-By: Andreas Igel <andreas.igel@computacenter.com>
@AndreasIgel

Copy link
Copy Markdown
Owner

moved to java-helpers#283

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant