Randgalt / record-builder

Record builder generator for Java records

Geek Repo:Geek Repo

Github PK Tool:Github PK Tool

Generate methods in builder to add a single value to collections

tmichel opened this issue · comments

First of all thanks for making this library. Awesome project.

I'm in the middle of upgrading a project that heavily uses Lombok. We use @Value + @Builder a lot so the combination of Java records and @RecordBuilder is a really nice upgrade path.

Lombok provides a way to generate "singular" builder methods using @Singular annotation on a field. It would be really nice if the builder generated with @RecordBuilder would include these by default.

Immutables also generate builder methods to add single items to collections.

An example:

public record Foo(List<Item> items) {}

The generated builder would include

// this exists today
public FooBuilder items(List<Item> items) {
    this.items = items;
    return this;
}

public FooBuilder item(Item item) {
     items.add(item);
     return this;
}

// or
public FooBuilder addItem(Item item) {
     items.add(item);
     return this;
}

Yeah, I think this could be good but it needs to be done right. Immutables keeps an internal collection of the appropriate type in its builder. RecordBuilder is not doing that currently. So, we need to come up with a way to make this work but not have to re-write the internals of RecordBuilder.

A first blush answer is to simply say: use Guava's ImmutableList.Builder (et al). Or maybe RecordBuilder could expose a builder of that type or something for this purpose. Maybe:

public Consumer<Item> itemBuilder() {
     return ...;  // the same consumer would always be returned so it could be re-used, etc.
}

In the general case I guess using Guava's ImmutableList.Builder can work. Unfortunately in my use case I could not use a separate builder for the collection. I'm reducing a stream of items and using the builder instance as the identity:

stream.reduce(FooBuilder.builder(), 
    (builder, row) -> builder.item(row.item()).count(row.count()));

What Lombok does in this situation might be a good compromise. Using @Singular generates a slightly different builder:

  • The collection field cannot be set directly anymore.
  • There are methods to mutate the collection:
    1. Add a single item
    2. Add all items from another collection instance
    3. Clear the collection
  • In build it passes an unmodifiable copy of the collection to the newly created instance.

Basically when it comes to collections there are two kinds of builders:

  1. Takes control of the collection and does not allow setting it directly.
  2. There are no methods to mutate the collection.

I guess this duality would mean that this is an opt-in feature so there must be an explicit option or annotation to turn on generating these methods. Maybe this could be an option in RecrodBuilder.Options which would imply that every field is affected in the record or there could be an annotation that applies to record components in which case only the annotated collections are affected. I would prefer an annotation because that gives more control over which fields are affected and it could also be used on the record itself and in that case it would affect all fields.

An example where only certain fields are affected. I'm using @Singular for simplicity but there might be better names for this.

@RecordBuilder
public record Foo(@Singular List<Item> items, Set<Baz> baz) {
}

// Generated builder
public class FooBuilder {
  private List<Item> items;
  private Set<Baz> baz;

  public FooBuilder item(Item item) {
    if (items == null) items = new ArrayList<>();
    items.add(item);
    return this;
  }

  public Foo build() {
    List<Item> items;
    if (this.items == null || this.items.size() == 0) {
      items = Collections.emptyList();
    } else if (items.size() == 1) {
      items = Collections.singletonList(items.get(0));
    } else {
      items = Collections.unmodifiableList(this.items);
    }

    return new Foo(items, this.baz);
  }

  public FooBuilder items(List<Item> items) {
    if (this.items == null) this.items = new ArrayList<>();
    this.items.addAll(items);
    return this;

  }

  public FooBuilder clearItems() {
    if (items!=null) items.clear();
    return this;
  }

  public FooBuilder baz(Set<Baz> baz) {
    this.baz = baz;
    return this;
  }
}

@Singular could be used on the record in which case mutation methods are generated for all collections.

@RecordBuilder
@Singular
public record Foo(List<Item> items, Set<Baz> baz) {
}

It turned out to be simpler than I thought so I just added it. Let me know what you think. #74

I made some simplifying assertions here. When this option is enabled, the builder always creates internal collections for List, Set and Map. Then, the setters/adders merely add to those internal collections. This gets 90% of the feature needed with little disruption and complication.