Unreleased: 0.4.11
Not released yet: these changes are on main, and the latest book describes them.
To try them, depend on 0.4.11-SNAPSHOT from the snapshots repository, as Gradle SNAPSHOT Configuration shows.
This release maps the wires that generated clients produce: openapi-generator models, protobuf-java messages, Lombok builders, and beans that are only read or only written. The processor now stops at your declaration, with a fix that compiles, on shapes that used to fail inside generated code or map the wrong value without a word. Generated code no longer fails a -Werror build over raw types, redundant casts or Higher-Kinded-J's own annotations.
- Generated client models map as they are generated. That covers openapi-generator's
JsonNullablecompanions and read-only properties, protobuf-java messages with oneofs andFieldMaskupdates, and Lombok's@Singularand@SuperBuilder. - Mappings share more. One mix-in vocabulary serves every spec that extends it, and specs nest, dispatch and merge across modules.
- More failures are located errors. A record's own invariant, which used to throw, and a
nullat any depth, which used to pass, now join the accumulated errors. - Parsing a bad wire got cheaper. A wire with five bad fields parses in about half the time, and 100,000 failures accumulate in milliseconds.
- Other areas gain smaller improvements.
toEitherPathtakes a supplier for its error,MaybegainstoOptional()andstream(), and generated optics compile under-Xlint:rawtypesand-Xlint:castwith-Werror. - Some changes alter what a running program does. Most surface as a compile error that names its fix, so read Upgrading from 0.4.10 first.
Mapping
New wires and shapes
- protobuf-java messages map both ways (#954): the processor reads a message by its fields, on the full runtime and the lite one. A oneof maps to a sealed type, and an
UpdateSpecgeneratesupdateFrom(message, mask), which edits only the fields aFieldMasknames. See protobuf-java messages. - openapi-generator models map as the generator writes them (#936, #935, #992): the processor leaves out the
getX_JsonNullable()pair thatopenApiNullable=trueadds. A@ReadOnlymarker reads a property that has a getter and no setter, and a model holding such a model maps both ways. See Properties you only read. - A bean that is only read, or only written, maps one way (#703): a read model with no writer generates
parsealone, and a write model with no getters generatesbuildalone.ValidatedPrismnow extends two halves,ValidatedParseandValidatedBuild, so a one-way mapping nests wherever its direction is used. See One-directional beans. - Lombok
@Singularand@SuperBuilderbuilders map (#934): a@Singularcollection is written whole through its collection setter, and a@SuperBuilderbean, whosebuild()is declaredC build(), maps both ways. See A Lombok@Singularcollection. - A field that is renamed and converted maps (#981): for a wire's
emailAddressstring against a domain'semail: EmailAddress, put@MapFieldon the component's leaf. It then means the rename and the conversion together. See Renamed and converted. - A record with no components maps (#983): a sealed hierarchy can now have an empty subtype, such as
record Collected(). See Sealed hierarchies. @Flattenspreads a nested component across a flat wire (#674): a customer'sAddressmaps to a wire carryingstreet,cityandpostcode, in both directions. A failure locates under the domain path, such asaddress.street. See Flattening a nested component.Set, reference array andMap-key components lift element by element (#675): through the component's leaf or nested spec. AMap's keys convert through@MapKey, an array element locates by index, and a set element by its own rendering. See Converting Map keys.- An optional object or list maps through its elements' spec (#825, #860): a domain
Optional<Address>against a nullableAddressDtonests through the spec for the pair, and so does an optional list. A record wire needs only the bare@OptionalBridgemarker. See Optional nested objects. @OptionalBridgemaps anOptionalto a nullable record component (#673): the component then behaves as a bean property does.parsereadsnullas empty, andbuildwritesnullfor an emptyOptional. See Optional fields.- A narrower bean wire with a reference-typed property takes the validated
patch(#702): as a record projection does, validating every projected property and reading the rest from the domain. See Bean projections.
Sharing across specs and modules
- Specs nest, dispatch and merge across modules (#676): a compilation finds the specs on its classpath through an index the processor writes beside each Impl. A spec in your own compilation wins over one from a dependency. See Across modules.
- One mix-in vocabulary serves every spec that extends it (#826, #835, #836): an inherited member that binds to nothing stays inert. One interface then serves a full mapping, its projections, its PATCH sibling and its sealed dispatch. See What an inherited member binds against.
- A vocabulary published from another module keeps its meaning (#844):
@MapField,@OptionalBridgeand@MapKeyare now kept in the class file. One API module can own the vocabulary its service modules extend. See Across modules. - A spec can name a type another annotation processor generates (#861): the spec waits until the type is written, and so does any spec that nests it. See Mapping over types other processors generate.
Nulls, copies and invariants
- A record's own invariant is reported as a located error (#872): a
RuntimeExceptionfrom a compact constructor becomes aFieldErrorat the record's path, such asranges.1: lo > hi. One bad value no longer turns a 422 into a 500. See A record's own invariants. - The
fields()builders end inconstructfor a constructor that may refuse (#897):construct(Range::new, "not a valid Range")gives a hand-written assembly the same guard, onValidated,PathandEitherOrBoth. See When the record refuses. - A sparse
UpdateSpecconstructs the domain record once (#893): a constructor that checks its fields against each other sees only the values the PATCH ends on.Edits.accumulate(focus, edits...)does the same for a hand-written PATCH. See Fields a constructor checks together. - The null scan reaches every level of a copied container (#875): a
nullinsideList<List<String>>, anArrayListor any otherCollectionorMaplocates at its full path, such asgrid.0.1. See The null contract, precisely. - The wire and the domain no longer share a container (#852): a same-typed
List,Set,CollectionorMapcrosses as an unmodifiable copy, and an array as a clone. A declared subtype, such asArrayList, still crosses as it is. See Same-typed containers cross as copies. - An empty
Optionalwritesnullto a bean (#871): a bean's own field defaults no longer read back throughparseas present values. See Bean-shaped wire targets.
Refused where you wrote it
- The processor refuses more shapes at the spec, naming the fix. Most used to fail inside generated code, or to map the wrong value silently. Rules and Limits lists every limit, and the new ones are:
- a type the spec's package cannot see (#918)
- a bridged
Optionalonto a site declared non-null (#881) - a bean accessor with no partner, where leaving it out would drop a value, unless
@Unmappednames it (#868) - a spec extending both
MappingSpecandUpdateSpec(#837) - a spec member the generated Impl cannot carry (#762)
- a getter-only
Listasked to carry absence, or declared raw or with a wildcard (#830, #839, #841)
- A spec that holds its Impl in a constant draws a warning (#984): the constant can read
nullonce the spec has a leaf. Bind the Impl in the calling code, or keep it with@SuppressWarnings("impl-constant"). See A spec never holds its Impl in a constant. - Refusals offer fixes you can paste (#882, #919, #927, #985): each fix line compiles, or the message says the shape is not supported yet. A spec's own
@MapKeyleaf beside a whole-Mapleaf is now refused rather than bypassed. See Compiler Messages. - A wider wire's refusal names the components nothing fills (#933): a generated class's extra accessors are named, where they used to be counted. See Generated clients: a checklist.
- An overloaded bean setter pairs by the getter's type (#933): whatever order the overloads are declared in. See How a bean is read and written.
- A PATCH refusal over a domain
Optionalsuggests anOptionalproperty (#680): for a plain property against a domainOptional, since that shape already carries JSON Merge Patch's three states. See Sparse PATCH.
Performance
- A generated mapping reads each leaf once (#952, #947): a leaf that builds its codec builds it once per Impl, and the stock codecs reject most malformed values without throwing. A wire with five bad fields parses in about half the time, faster than Bean Validation. See Your own canon.
- Many failures accumulate in time proportional to their number (#982): 100,000 failing elements take milliseconds rather than seconds.
Edits.combine,Edits.accumulateandMonoids.update().combineAllapply any number of edits without overflowing the stack. See Multi-Edit and Sparse Updates. - A wide PATCH bean compiles in under a second (#893): one with 64 properties used to take about 15 seconds.
Smaller changes
- An element-mapped spec's
of(...)takes the spec's own leaves first (#876): inherited leaves follow, in the order theextendsclause names their mix-ins. The generated factory documents each parameter. See Leaf order inof(...). - A derived field fills a primitive wire component (#954): declare it over the wrapper, such as
Getter<Domain, Integer>for anint. See Derived wire fields. - Mapping works on a compiler that cannot name a type's source file (#859): with two limits. A spec waits only for a generated type it names itself, and a package two modules declare reads as a class that already exists. Builds with javac are unaffected.
- A domain
Optionalover a wildcard-carrying element compiles (#838): such asOptional<List<? extends CharSequence>>, and a present list is scanned fornullelements. - A package that two modules declare is reported by name (#861): where javac refuses to write a generated class, the message names the shared package and the modules declaring it.
Optics
- Every
@ImportOpticsgenerates what it describes, or says why (#908): a spec that inherits its optics, or an empty class list, is reported at the annotation. It waits for a type another processor writes, and the ten notes it printed per spec are gone. See Compiler Errors. - A
@Witherlens calls the overload its focus binds (#887): where a wither is overloaded, the processor checks the method javac will choose. Every method name a copy strategy carries is checked at the spec, not left tocannot find symbol. See Copy Strategies. @ImportOpticsimports an inner class of a generic class (#878): its lenses are declared under the enclosing class's type parameters, such as<X> Lens<Outer<X>.Line, String>. A wither pairs only when it hands back the class. See Wither Classes to Lenses.- Generated code calls a record's canonical constructor (#874): even beside a same-arity overload, such as
Money(Number major, String currency). That covers generated lenses, setters and Focus paths, and the mapper'sparse, merges andassemble(). See Every Write Runs the Canonical Constructor. @ThroughFieldchecks the container and the focus at the declaration (#773, #779): a field declared asArrayListorTreeMap, which threwClassCastExceptionon first use, is refused with the interface to declare. So is a focus that does not match the element. See@ThroughFieldauto-detection.- A dependency's
@GenerateFocusrecord is navigable (#847): the annotation is now kept in the class file, so navigation reaches into a record from a jar. See Which fields get a navigator. Kindfields take a wildcard element (#787):Kind<ListKind.Witness, ? extends Role>widens toTraversalPath<Holder, Role>, asList<? extends Role>already did. See Kind Field Support.- An imported record's containers traverse a wildcard element (#873):
List<? extends Number>focusesNumber, as it does under@GenerateTraversals. See Container Fields Get Traversals. - Generator priority decides on every route (#774): the highest
priority()wins for@GenerateTraversals, the Focus DSL and@ImportOptics, and a tie draws a warning on each. See How Plugin Discovery Works. @TraverseFieldsays when it is not applied (#789, #790): a note names the component and what would make the annotation apply. See Compiler Errors.@GenerateFocusaccepts a wildcard container that nothing would widen (#758): such a component stays a plainFocusPath. See Supported container types.- Optics messages point at your declaration (#759, #771, #900): an
OpticsSpecover a raw type is refused where it is declared, and a raw lens under@ThroughFieldis named as raw. A message names a type without its type-use annotations. See Compiler Errors.
Effect Paths
MaybePath,OptionalPathandAffinePathtake a supplier for the error (#794):toEitherPath(() -> new NotFound(id))builds the error only on the branch that uses it. See Type Conversions.Maybebridges to the JDK (#784):toOptional()completes the round tripfromOptionalstarted, andstream()lets.flatMap(Maybe::stream)keep a pipeline's hits. See Maybe Monad.- A
@PathSourcePath'speekruns with its effect (#949): on a lazy witness such asIO, the action now runs when the effect runs. @PathSourceapplies to a record, and waits for a generated witness (#949): a witness another processor writes in the same build, such as one from@EffectAlgebra, now works.@PathSourcechecks its attributes at the annotation (#945): asuffix,targetPackage, witness or primitiveerrorTypeit cannot use is refused with a message saying why. Each used to fail inside the compiler or the generated file.@PathConfigand two@PathSourcecapabilities are deprecated for removal (#890, #945): no processor ever read@PathConfig, andEFFECTFULandACCUMULATINGgenerate whatCHAINABLEandRECOVERABLEdo. See 0.5.0 deprecation migration.
Spring
- A client keeps the
@OnStatusoverrides it inherits from a jar (#849): the annotations are now kept in the class file, and an inherited override is checked as a local one is. See Declarative HTTP clients.
Testing
hasFieldErrorsasserts anInvalid's located errors as rendered lines (#691):assertThatValidated(result).hasFieldErrors("email: not an email address")checks every error, in declaration order. SeeMaybeAssertandValidatedAssert.MappingLawschecks each direction on its own (#703, #935): a mapping that only parses, only builds, or carriesasValidatedParse()andasValidatedBuild()withoutasValidatedPrism()is law-checked one direction at a time. See Injecting and testing generated mappings.
Build and tooling
- Generated code compiles under
-Xlint:rawtypesand-Xlint:castwith-Werror(#865, #867, #873, #877): a strict build no longer fails inside generated code over a raw type you wrote, or a redundant cast. Onlyrawtypesis suppressed. See Build-time impact. - The annotations Higher-Kinded-J reads draw no
-Xlint:processingwarning (#877): a newCompanionAnnotationProcessorclaims@MapField,@Wither,@TraverseFieldand the others the processors read without generating for. It generates nothing, and incremental compilation is unaffected. See Build-time impact. - Generated classes declare their constructors, and cast nothing redundant (#877): the classes
@EffectAlgebraand@HkjHttpClientgenerate draw nocastormissing-explicit-ctorwarning. - Type-use annotations in generated code follow what compiles (#895): one on a type variable, such as
@Nullable T, is kept, and one the generated file cannot compile is left off.TraversableGeneratorgains agenerateModifyFoverload carrying the package (#900). See Generator plugins. - A dependency's spec with a mix-in off the classpath is passed over (#895): the use site's error names the missing type, where javac used to report
cannot accessinside generated source. See Multi-module builds. - Every processor runs from the module path (#889): all twenty run there, and
Path.from()finds aVStreamPath. Builds that use Gradle'sannotationProcessoror Maven's<annotationProcessorPaths>see no change. See PathProvider SPI Registration. - Coverage tools skip every generated type (#798, #817): nested generated types carry
@Generatedthemselves, and traversals and folds are named nested classes rather than anonymous ones. See Build-time impact.
Documentation
- The Mapping chapter starts with a quickstart to your first 422: Quickstart: Your First 422 leads, and Absent Fields and Record Invariants and Bean-Shaped Wires get pages of their own.
- Four lookup pages answer the questions mapper users arrive with: Mapper at a Glance, Coming from MapStruct and Bean Validation, Rules and Limits and Compiler Messages.
- A second capstone maps one domain across three modules: Capstone: An Estate in Three Modules, and Check Your Understanding closes the first route.
- The mapping benchmark sets parse and build against MapStruct (#948): see The Mapping Benchmark.
- The Lenses page says when a wither is enough (#997): a wither, Lombok's
@Withincluded, suits a change one level deep made once. A composed lens reaches any depth in one call, and updates by function or by effect. See Why a lens, when you have@With?. - Both compiler-error catalogues open with a table of messages (#819): the Optics compiler errors and the Effect Path compiler errors.
- An untyped sealed request body is documented as a 500 (#942): Jackson's failure escapes the controller, where the book had said Jackson answers 400. See Sealed hierarchies.
- Tutorial 27, Boundary Edge Cases, is new (#959): it joins the Boundary Mapping journey.
Upgrading from 0.4.10
For most changes, the processor stops at your declaration on a shape that used to fail inside generated code or map the wrong value, and its message names the fix. What a running program can notice lists the changes it cannot point at: read that list first.
Before you upgrade
- Rebuild libraries that publish specs with 0.4.11 first. That means mapping specs, mix-ins,
@GenerateFocusrecords and@HkjHttpClientbase interfaces. Their class files now carry annotations a consuming build reads (#844, #847, #849). Both sides must agree on a bean's properties and a leaf order (#868, #876). - A module that declares specs ships index classes (#676): they live in
org.higherkindedj.mapping.index. Two such jars cannot load together as automatic modules, so a library bound for a module path passes-Ahkj.mapping.index=false. A module with its ownmodule-infowrites none. - A build that names its processors adds one (#877): with
-processoror Maven's<annotationProcessors>, addorg.higherkindedj.optics.processing.CompanionAnnotationProcessor. Put a processor claiming every annotation, such as Lombok's, ahead ofhkj-processor.
What a running program can notice
Mapping
- A leaf's answer is kept for the life of the Impl (#952): a leaf that picks its codec per call, from a flag or a system property, keeps its first pick. Make that choice inside the codec's parse, and build it from thread-safe parts, such as
DateTimeFormatter, since every thread shares it. currency()refuses a code with a lower-case letter (#952): such asXPt, which the JDK could misread as a currency unequal toXPT.- Containers cross a mapping as unmodifiable copies (#852): a list
buildorparsehands over throwsUnsupportedOperationExceptionwhen added to, so set a new one. A sortedSetorMaploses its comparator. An array is cloned, so a record with an array component and noequalsof its own no longer equals its round trip. - A
nullinside any copied container is a located error (#875): one nested deeper, in a raw container, or inside anArrayListor aLinkedHashMap, used to parse as valid. Anullkey in a newly scanned map throwsNullPointerException. - A constructor's exception becomes an
Invalid(#872, #893): fromparse,patch, a fallible merge,assemble()and a sparse update'sapply. At a Spring boundary a 500 becomes a 400 or 422, and the message reaches the client. Keep constructors to argument checks with client-ready messages. - An empty
Optionalwritesnullto a bean (#871): an outbound DTO that relied on a field initialiser to send a default no longer sends it, so carry the default in the domain. A setter that refusesnull, such as one callingList.copyOf, now throws frombuild. - An explicit leaf now runs on a bridged component (#673, #860): a leaf on a bridged component whose types already match now runs, so a normalising leaf changes the output. A bridged container comes back as a new unmodifiable list.
- An element-mapped spec's
of(...)can change parameter order (#876): only where its abstract leaves are spread across several interfaces. Where the leaf types coincide, or the result is held invar, an old call still compiles and swaps the prisms, so check each call against the parameters the Impl documents. - A
Boolean isX()getter is a property (#868): a property read only through one now maps, sobuildwrites it andupdateFromapplies it. - Reflection finds the moved
ValidatedPrismmethods on their half (#703):getDeclaredMethodfindsparseonValidatedParseandbuildonValidatedBuild.
Optics
- Generated code calls a record's canonical constructor (#874): on a record with a same-arity overload, or a class rebuilt through an overloaded constructor, wither or setter, the value built may differ. Check stored data and test expectations recorded through the overload.
- A lens calls an overloaded wither's primitive form (#887): on a class imported by class literal, the lens now calls
withN(int)rather than a boxed overload. Settingnullthrough it throws. combineAllon the update monoid rejects anullupdate (#982): withelements must not contain null.
Effect Paths
toEitherPath(null)now throws (#794): the call selects the new supplier overload, so cast thenullto the error type if aLeft(null)was meant. ASupplierpassed as the error itself now supplies the error.- A
@PathSourcePath'speekreturns a new Path (#949): on a lazy witness its action runs each time the effect runs. On an eager one it runs once, and an exception it throws reaches the returned Path. sequenceValidatedgroups its combines in balanced pairs (#982): any associativeSemigroupthat leaves its arguments alone gives the same result, andtraverseValidatedmatches it.- A Path with a custom
suffixprints its own class name (#945): fromtoString, where it printed the annotated type's name.
Testing
- A
MappingLawsrejection sample must fail on a field (#872, #893): a value only the constructor refuses now fails unlabelled, so a patch, parse-only or sparse law needs a sample with a bad component.
Build and tooling
- A deprecated type-use annotation is no longer copied (#895): a deprecated nullness annotation inside a
@NullMarkedscope now leaves the generated type non-null. Move to a current one, such as JSpecify's.
What stops a build that compiled
Most of these fail at your declaration, or warn there under -Werror, naming the fix. The rest fail where your code calls a signature that changed.
| Area | Now fails the build | Do this | Issue |
|---|---|---|---|
| Mapping | A spec or mix-in holding its Impl in a constant (a warning) | Bind the Impl in the caller, or suppress impl-constant | #984 |
| Mapping and optics | A type the spec's package, or a generated companion's, cannot see | Remove private, or make it public | #918 |
| Mapping | A bridged Optional onto a site declared non-null, a Lombok @NonNull field or a Kotlin setter included | Mark the site @Nullable | #881 |
| Mapping | An unpaired bean accessor named after a domain component, or a PATCH setter with no getter | Pair it, remove it, or name it in @Unmapped | #868 |
| Mapping | A Boolean isX(), now a getter, unmodelled beside setX(Boolean) or beside setX(boolean) | Add a domain component or derived field, or align the types | #868 |
| Mapping | A spec extending both MappingSpec and UpdateSpec | Split it into two specs sharing a mix-in | #837 |
| Mapping | A spec's own @MapKey leaf beside a whole-Map leaf for one component | Keep the one you mean | #882 |
| Mapping | An abstract @MapField method returning ValidatedPrism, now read as a leaf | Give it a body, or another return type | #981 |
| Mapping | A rename or derived field naming a JsonNullable companion, or a domain component named after one (no parse) | Remove it, or rename the component | #936 |
| Mapping | A getter-only List on a PATCH bean, or bridged from an Optional | Give it a setter, and a getter that answers null until set | #830, #841 |
| Mapping | A raw or wildcard getter-only List where build is emitted | Declare its element type, or add a setter | #839 |
| Mapping | A bean that mapped both ways over a getter-only List beside other getters | It now maps parse-only: remove calls to build | #703 |
| Mapping | parseAll(null), or prism::parseAll where nothing fixes the container type | Cast the null, or use a lambda | #675 |
| Optics | @ThroughField on a concrete container field, or with a focus the element does not match | Declare the interface, or name a traversal | #773, #779 |
| Optics | An @ImportOptics spec that inherits its optics or reaches OpticsSpec through another interface, a class written as a spec, a spec that also lists classes, a primitive, array or void literal, or an empty list (a warning) | Follow the message | #908 |
| Optics | An OpticsSpec over a raw type | Complete its type arguments | #771 |
| Optics | A wither returning a raw type, or a spec @Wither or strategy setter binding a static method | Return the class from an instance method | #878, #887 |
| Optics | A navigator's return type, or a wildcard container such as Map<String, ? extends Address>, once a dependency's @GenerateFocus record is rebuilt | Call .toPath(), declare exact type arguments, or list the field in excludeFields | #847 |
| Optics | Code holding a FocusPath for Kind<? extends ListKind.Witness, Role>, which now widens | Take the TraversalPath or AffinePath, and drop any hand-applied step | #787 |
| Optics | Two TraversableGenerator providers at one priority for one type (a warning) | Rank one of them | #774 |
| Effect Paths | A toEitherPath error whose own type is a functional interface, written as a lambda | Name the error type: path.<MyError>toEitherPath(...) | #794 |
| Effect Paths | @PathConfig, EFFECTFUL or ACCUMULATING (a [removal] warning), or a generic errorType on RECOVERABLE | Run MigrateDeprecationsTo0_5_0; use a non-generic error type | #890, #945, #949 |
| Spring | An inherited @OnStatus naming a type the method cannot return or the classpath lacks, or drawing the duplicate-status or MaybePath warning; a module reading it without hkj-spring-boot-client (a warning) | Fix the override, and expose the client with Gradle's api | #849 |
Deprecated for removal in 0.5.0
The MigrateDeprecationsTo0_5_0 recipe removes @PathConfig and replaces each capability. See 0.5.0 deprecation migration.
| Deprecated | Replacement | Recipe |
|---|---|---|
@PathConfig | Nothing, since it has no effect; to rename a Path, set suffix on @PathSource | RemovePathConfig |
@PathSource capability EFFECTFUL | CHAINABLE, which generates the same | ReplaceDeprecatedPathSourceCapabilitiesRecipe |
@PathSource capability ACCUMULATING | RECOVERABLE, which generates the same | ReplaceDeprecatedPathSourceCapabilitiesRecipe |