Database Records with JOOQ
Get lenses for a JOOQ record, or any type that copies through a builder, wither or constructor.
"First, solve the problem. Then, write the code."
– attributed to John Johnson
The problem is not "how do I update a field in a JOOQ record". It is "how do I express a domain transformation clearly while respecting immutability". Builders solve the immutability half; optics solve the composition half. A copy strategy is how you tell the processor which builder-shaped door this particular type opens.
- Generate lenses for a builder-pattern type with
@ViaBuilder, naming the getter, setter,toBuilderandbuildwhere conventions differ - Choose
@ViaBuilder,@Wither,@ViaConstructoror@ViaCopyAndSetfrom how a type makes a modified copy - Traverse a collection field's elements with
@ThroughField, and fix the refusal of a concrete container type - Check a
@ViaCopyAndSetlens withLensLawswhere a shallow or supertype copy constructor can lose state - Read through a type that already implements
List, such as JOOQ'sResult, with no strategy at all
The JOOQ Pattern
JOOQ generates immutable POJOs that copy through a builder, and it is far from alone: Lombok's @Builder, Immutables, AutoValue, Protocol Buffers and most hand-written immutable classes do the same.
// The chapter's Customer, as a POJO that copies through a builder
public final class Customer {
public String name() { ... }
public EmailAddress email() { ... }
public Builder toBuilder() { ... }
public static final class Builder {
public Builder name(String name) { ... }
public Builder email(EmailAddress email) { ... }
public Customer build() { ... }
}
}
There is no wither and no all-args constructor, so @ImportOptics auto-detection has nothing to latch onto. Declare the shape instead:
@ImportOptics
interface CustomerOpticsSpec extends OpticsSpec<Customer> {
@ViaBuilder
Lens<Customer, String> name();
@ViaBuilder
Lens<Customer, EmailAddress> email();
}
@ViaBuilder with no arguments assumes the common conventions: the getter and the builder setter are both named after the optic method, the builder comes from toBuilder(), and it finishes with build(). That is enough to generate real lenses:
Customer updated =
CustomerOptics.email().set(new EmailAddress("ada@work.example"), ada);
// a copy of ada with her new email; ada itself is unchanged
String name = CustomerOptics.name().get(ada);
Four annotated lines replaced a copy method per field, and what you get back is not a bespoke helper: it is a Lens, so it composes with every other optic in the library. With a customer() lens declared the same way, OrderOptics.customer().andThen(CustomerOptics.email()) is a lens from an order to its customer's email. It obeys the lens laws as long as the builder round-trips faithfully: toBuilder(), the setter and build() have to give back the value they were handed and leave every other component alone. A builder that normalises, defaults or drops a field breaks that, and so does a setter that adds to what toBuilder() copied instead of replacing it. The processor recognises two such setters, a Lombok @Singular collection's and a protobuf-java repeated or map field's, and clears the builder's copy of the collection first. No annotation can detect the others for you.
Non-Standard Naming
Conventions vary, so every part of the interaction is nameable:
// Lombok: @Builder(toBuilder = true, setterPrefix = "with"), JavaBean getters
@ViaBuilder(getter = "getId", setter = "withId")
Lens<Order, UUID> id();
// A legacy type that spells all four differently
@ViaBuilder(
getter = "getName", // how to read the current value
toBuilder = "newBuilder", // how to obtain a builder
setter = "setName", // how to set on the builder
build = "create") // how to finish
Lens<LegacyType, String> name();
Lombok's @SuperBuilder(toBuilder = true) needs nothing extra: on a class Pet, its toBuilder() hands back a PetBuilder<?, ?>, and with Lombok's @Getter, @ViaBuilder(getter = "getName") is enough. A spec over a parent class, OpticsSpec<Animal>, rebuilds a subclass as that subclass only when the subclass sets toBuilder = true too, since its own toBuilder() is then the one called. Without it, the subclass inherits its parent's toBuilder(), which builds the parent and drops the subclass's fields. Lombok writes no toBuilder() on an abstract class, so declare a spec over each concrete class. A lens over a @Singular collection replaces it. For a @Singular List<String> tags, whose setter adds to what toBuilder() copied, the generated lens first calls the clearTags() Lombok writes beside it.
A protobuf-java message needs its getter and setter named. A repeated field tags is read by getTagsList() and set by addAllTags, so its lens is @ViaBuilder(getter = "getTagsList", setter = "addAllTags"). A map field points is read by getPointsMap() and set by putAllPoints. Each adds to what toBuilder() copied, so the generated lens first calls the clearTags() or clearPoints() protobuf writes beside it. An open enum's numbers work the same way, through getKindsValueList() and addAllKindsValue, and that lens keeps the laws for a number the enum does not name, which the enum-typed lens cannot set. A member of a oneof, or a field that tracks presence, keeps the lens laws only where it is already set. Setting it selects that member, or marks the field present, even to the value its getter reads when it is unset.
Reaching Into Collections with @ThroughField
The chapter's Order, as the same kind of builder POJO, holds its lines in a List. A lens to a List field is rarely what you want; you want a traversal into its elements. @ThroughField composes the two, detecting the right element traversal from the field's type:
@ImportOptics
interface OrderOpticsSpec extends OpticsSpec<Order> {
@ViaBuilder
Lens<Order, List<LineItem>> lines();
@ThroughField(field = "lines")
Traversal<Order, LineItem> eachLine();
}
// Read every line's price
List<BigDecimal> prices =
Traversals.getAll(
OrderOptics.eachLine().andThen(LineItemLenses.price()), order);
// one price per line, in the order's line order
// Raise all of them by 5%
Order raised =
Traversals.modify(
OrderOptics.eachLine().andThen(LineItemLenses.price()),
price -> price.multiply(new BigDecimal("1.05")).setScale(2, RoundingMode.HALF_EVEN),
order);
// Only the ones already above a threshold
Order discounted =
Traversals.modify(
OrderOptics.eachLine()
.andThen(LineItemLenses.price())
.filtered(price -> price.compareTo(new BigDecimal("20")) > 0),
price -> price.subtract(new BigDecimal("5")),
order);
@ThroughField Auto-Detection
| Lens focus for the field | Auto-detected traversal |
|---|---|
List<A> | Traversals.forList() |
Set<A> | Traversals.forSet() |
Collection<A> | Traversals.forCollection() |
Optional<A> | Traversals.forOptional() |
A[], A a reference type | Traversals.forArray() |
Map<K, V> | Traversals.forMapValues() |
Detection reads the focus of the spec's own lens for the field, which is the lens the generated traversal composes with, so a spec that declares no lens for the field is refused. The match is on the interface itself. A lens focusing something narrower is refused at the declaration: a concrete container (ArrayList<LineItem>, HashSet<Tag>, TreeMap<K, V>, HashMap<K, V>) or another interface (Deque, SortedSet). Each standard traversal promises no more than the interface type, forList() hands back an unmodifiable List, and the field could not take that value back, so the generated traversal would throw ClassCastException on first use. An array of a primitive (int[]) is refused for the same reason, since the array traversal walks an Object[].
Two routes get you past a refusal. Where the type is yours, declare the field as the interface. Otherwise name a traversal that rebuilds the declared type, built with Traversals.forIterableCollecting(ArrayList::new) for a list-shaped container or Traversals.forMapValuesCollecting(TreeMap::new) for a map, exposed as a static method:
@ThroughField(field = "entries", traversal = "com.example.CustomTraversals.forMyContainer()")
Traversal<MyType, Entry> eachEntry();
The method's declared focus is held to what the traversal hands back the same way: the element type, Map's value type, or Object where the element sits behind a super- or unbounded wildcard. A focus that does not contain that type could only compile through a cast, which would throw ClassCastException on first use where it narrows and let ill-typed writes through where it widens, so it is refused at the declaration. A wildcard focus over the element is accepted, as in ? extends CharSequence over String elements.
Where the lens focus names its own type arguments outright, with no wildcard among them (a nested List<List<?>> still qualifies), the auto-detected composition is generated with no cast and no suppression, so javac checks it end to end. An explicit traversal keeps the cast, as the author's undertaking that theirs rebuilds the declared type.
Reads and writes through a bare Traversal go through the Traversals utility: Traversals.getAll(traversal, source) and Traversals.modify(traversal, f, source). The instance methods are andThen, filtered, filterBy, asFold, modifyF, modifyWhen and branch; asFold() is how you reach the read side, as in traversal.asFold().foldMap(...). (A TraversalPath from the Focus DSL does carry getAll and modifyAll directly, which is often the more comfortable surface.)
The Other Three Strategies
Every strategy writes the focus through something that can be overloaded: the constructor @ViaConstructor rebuilds through, the @Wither method, the builder's setter, the setter @ViaCopyAndSet calls. A lens focuses a primitive boxed, and Java offers an overloaded call the candidates needing no unboxing first, so a Long reaches a withCents(Number) before a withCents(long).
The focus is therefore unboxed to its getter's type wherever one of the candidates takes exactly that type. The call then binds where new Money(source.cents(), source.currency()) would, every argument the type its own getter hands back. Where no candidate takes that type, as when the constructor takes the wrapper, unboxing could move the call somewhere the boxed focus never reached, so it is passed as it is. A focus wider than the wrapper, Number over an int getter, is never unboxed, since it may hold a value of another kind. Declare the focus as the wrapper where only the primitive is taken.
That leaves one shape to watch: a getter returning Long where the constructor or method takes long, beside one taking Number, reaches the Number one and sets whatever it computes. Have the getters return the types the constructor and methods take, and run LensLaws over the lens.
@Wither: types with withX() methods
@ImportOptics
interface MoneyOpticsSpec extends OpticsSpec<Money> {
@Wither(value = "withCents", getter = "getCents")
Lens<Money, Long> cents();
}
Naming both halves explicitly is what makes this strategy work where auto-detection cannot: @ImportOptics requires the getter's return type to match the wither's parameter exactly, and here you simply say which pair to use. The wither still has to hand back the source type, read under the arguments the spec gives it: on a Draft<T>, a Draft<String> withId(String) serves an OpticsSpec<Draft<String>>, and a wither that returns the type raw, or as a supertype, is refused at the spec method. Where the wither is overloaded, the one checked is the one the call binds: the lens passes the new value typed by its focus, Long here, and javac chooses among the overloads by that type. A name the type does not have, or that the generated class cannot call, one none of whose overloads takes the focus, a choice javac could not make, and a static method are each refused at the spec method too. See the wither entries in Compiler Errors.
A wither that returns its class's own type parameter, as S withName(String) does on a Shape<S extends Shape<S>>, works on a spec over Shape<?>, since the call still hands back a Shape.
Every other method name a strategy carries is checked the same way, at the spec method rather than in the generated file: the getter each one reads through, which for @ViaCopyAndSet and @ViaConstructor is the lens method's own name; @ViaBuilder's toBuilder, setter and build, each read on what the step before it hands back; and each accessor a @ViaConstructor parameterOrder names, along with the constructor that order rebuilds through. An accessor that reads a type the lens cannot hand back as its focus is refused too, which is the LocalDate.getMonth() pairing a spec interface exists to sort out. So is a read or rebuild javac would report as unchecked, such as a raw List handed back as a List<String> focus, or a setter called through a raw builder. That warning would land in the generated file, beyond the reach of your own @SuppressWarnings. See the unchecked conversion entry in Compiler Errors.
@ViaConstructor: constructor-only value types
@ImportOptics
interface PointOpticsSpec extends OpticsSpec<Point> {
@ViaConstructor
Lens<Point, Integer> x();
@ViaConstructor(parameterOrder = {"x", "y"})
Lens<Point, Integer> y();
}
The x() lens rebuilds through new Point(newValue, source.y()). parameterOrder names the accessors it reads each argument through, in the order the constructor takes them, with the lens method's own name where the new value goes.
Leave it out, as x() does, and the order is read from the constructor's parameter names. Point(int x, int y) gives {"x", "y"}, because each parameter is named after an accessor that reads a value it takes: x(), getX() or isX() for a parameter x. Write the order when no constructor's names give one, as for a class compiled with neither -parameters nor -g, or when two constructors give different orders. Write it too when a longer constructor than the one the names describe might hold more. A record's canonical constructor holds every value the record has, so for a record only that constructor counts as the longer one. A lens no constructor takes a parameter for, such as one reading a value the class works out, cannot be rebuilt through a constructor at all, and needs another strategy.
Either way, the order is held to the constructor the generated call binds. An order of the wrong length is refused at the spec method. So is one that passes y() where the parameter named x stands, which would otherwise compile and write each value into the other's field. A parameter named after nothing the type reads leaves its place to the order. See the @ViaConstructor entry in Compiler Errors.
A written order can rebuild through a shorter constructor, one that works out or defaults a value the source holds. Where a longer constructor takes a value the order passes nothing for, or a record component is left out, the processor warns. After every set, that value reads what the shorter constructor gives it. Pass it, in the order the warning offers where the names give one. Where the constructor works it out on purpose, annotate the lens method @SuppressWarnings("shorter-constructor"). A variable-arity constructor is held to its parameter names too, the array's included. See the warning's entry.
@ViaCopyAndSet: legacy types with a copy constructor and setters
@ImportOptics
interface HostSettingOpticsSpec extends OpticsSpec<HostSetting> {
@ViaCopyAndSet(setter = "setHost")
Lens<HostSetting, String> host();
}
A setter spelt as a wither, such as withHost, is taken at its word. Where every method of that name the new value can be passed to returns a HostSetting, the lens hands back what the wither returns: a new value, or the copy a fluent wither set. @Wither reaches the same value without the copy. A with method that returns anything else, such as its argument, is taken at its word too, so rebuild that type with @ViaBuilder or @ViaConstructor. Any other setter is called for its effect, whatever it returns, since a set method that returns a HostSetting may hand back the value it replaced. A setter spelt as a wither that may return a HostSetting, but whose result the lens cannot keep, draws the dropped-result warning.
@ViaCopyAndSet copies, then mutates the copy. That is lawful only if the copy constructor really copies everything: a shallow copy that shares a mutable field means a "set" can be seen through the original, which breaks the lens laws in the most confusing way possible. Verify with LensLaws on a type where this matters.
copyConstructor adds a second way to lose state, and it is quieter. Naming a supertype selects the constructor that takes it, and that constructor can only copy what it can see: if balance is declared on Ledger and you name LedgerBase, every set returns a copy with balance back at its default. That is a perfectly deep copy of everything in scope; the field is simply not in scope. The processor cannot check this for you, so the narrower the type you name, the more LensLaws is worth running.
copyConstructor is for the one case the default cannot express: an overloaded constructor. new HostSetting(source) picks the most specific applicable overload, which is what you want almost always, since a lone HostSetting(BaseHostSetting other) already takes the source by widening. Name a supertype, fully qualified, and the source is passed under that type instead:
public class Endpoint extends BaseEndpoint implements Audited {
public Endpoint(BaseEndpoint other) { ... }
public Endpoint(Audited other) { ... } // new Endpoint(source) is ambiguous
public void setHost(String host) { ... }
}
@ImportOptics
interface EndpointOpticsSpec extends OpticsSpec<Endpoint> {
@ViaCopyAndSet(copyConstructor = "org.higherkindedj.example.book.optics.BaseEndpoint",
setter = "setHost")
Lens<Endpoint, String> host(); // new Endpoint((BaseEndpoint) source)
}
The name is a plain string, so it is not resolved against the interface's imports: give it fully qualified, the class alone with no type arguments, and a nested class as com.example.Outer.Base. Four names are rejected at the declaration rather than generating a cast javac cannot compile: one that does not resolve, one naming a type Endpoint does not extend or implement, one the generated class cannot see, and one no Endpoint constructor accepts. What the processor cannot check is whether the constructor it picks copies everything; see the warning above.
When You Need No Strategy at All
JOOQ's into(Customer.class) maps a Result's rows into a plain List of the POJOs, and a plain List needs no strategy: the standard traversals already cover it.
Result<CustomerRecord> rows = ctx.selectFrom(CUSTOMER).where(CUSTOMER.ACTIVE.isTrue()).fetch();
List<Customer> customers = rows.into(Customer.class);
// Read straight through the list traversal
List<EmailAddress> emails =
Traversals.getAll(
Traversals.<Customer>forList().andThen(CustomerOptics.email()), customers);
Writing through the same traversal gives back a new List<Customer>. To write into the Result itself, traverse its CustomerRecords with Traversals.forIterableCollecting(rebuild), which takes the rebuild as a function from the new list to the container.
Choosing a Strategy
How a type already makes a modified copy picks its strategy; a List, Set, Map, Optional or array needs none:
flowchart LR
accTitle: Which copy strategy a type needs
accDescr: A type that copies through toBuilder, a field setter and build takes ViaBuilder; one with a withField method takes Wither; one with an all-args constructor takes ViaConstructor; and one with a copy constructor plus setters takes ViaCopyAndSet. A type that is already a List, Set, Map, Optional or array needs nothing but the standard traversals.
Q{"How does the type<br/>make a modified copy?"}
Q -->|"toBuilder()<br/>.field(x).build()"| B["@ViaBuilder<br/>JOOQ, Lombok,<br/>Immutables, AutoValue"]
Q -->|"withField(x)"| W["@Wither<br/>java.time, Guava,<br/>Immutables"]
Q -->|"an all-args<br/>constructor"| C["@ViaConstructor<br/>simple value objects"]
Q -->|"a copy constructor<br/>plus setters"| S["@ViaCopyAndSet<br/>legacy mutable types"]
Q -->|"it is already a List, Set,<br/>Map, Optional or array"| N["nothing:<br/>use the standard traversals"]
classDef decision fill:#e5c890,stroke:#df8e1d,color:#232634
classDef tier fill:#a6d189,stroke:#40a02b,color:#232634
class Q decision
class B,W,C,S,N tier
Start with @ViaBuilder: it is the pattern most generated code uses. Fall back to the others when the type does not fit.
The fine print: strategy parameters
@ViaBuilder(
getter = "", // default: the optic method's name
toBuilder = "toBuilder",
setter = "", // default: the optic method's name
build = "build")
@Wither(
value = "withName", // required: the wither method
getter = "") // default: the optic method's name
@ViaConstructor(
parameterOrder = {"x", "y"}) // default: read from the constructor's parameter names
@ViaCopyAndSet(
copyConstructor = "", // default: pass the source unchanged; else a fully qualified supertype of S
setter = "setHost") // required
@ThroughField(
field = "items", // required: the container field
traversal = "") // default: auto-detected from the field type
@ViaBuilderis the default choice, and covers JOOQ, Lombok, AutoValue and Protobuf between them. Immutables generates both a builder and withers, so either strategy works there.- Every name is overridable. Getter, builder accessor, setter and build method can each be spelled out when a library's conventions differ, and
@ViaCopyAndSet(copyConstructor = ...)picks between overloaded copy constructors. @ThroughFieldreaches into collection fields, auto-detecting the traversal for a field declared asList,Set,Collection,Map,Optionalor an array; a concrete container type is refused with the remedy named, and an explicittraversalcovers it.Traversalreads and writes throughTraversals, not through a plain instancemodify;andThen,filtered,filterBy,asFold,modifyF,modifyWhenandbranchdo live on the optic.- Not everything needs a strategy. A type that already implements
List,MaporOptionalworks with the standard traversals for reads, though rebuilding the exact container type needsforIterableCollecting.
- Optics for External Types:
@ImportOpticsand what auto-detection covers - Taming JSON with Jackson: spec interfaces for predicate-based type discrimination
- Focus DSL with External Libraries: bridging Focus navigation into these generated optics
- jOOQ: jooq.org: type-safe SQL in Java, and its immutable POJO generation
- Lombok: projectlombok.org:
@Builder,@SuperBuilder,@Value, and thesetterPrefixoption this page's naming examples assume - Protocol Buffers: protobuf.dev: generated builders, a natural
@ViaBuildertarget
Previous: Taming JSON with Jackson Next: Focus DSL with External Libraries