Skip to content

Latest commit

 

History

History
477 lines (399 loc) · 24.5 KB

USAGE.md

File metadata and controls

477 lines (399 loc) · 24.5 KB

datus usage guide

Overview

  1. Prerequisites
  2. Getting started
  3. Basics
  4. Immutable API
  5. Mutable API
  6. Examples
  7. Sample projects
  8. Configuration options
  9. Advanced usage / FAQ
  10. Closing words

Prerequisites

datus requires at least basic knowledge about functional programming and fluent APIs. A basic understanding of Java 8 Optional and/or Stream classes and their APIs (map, filter, collect, orElse etc.) should suffice to understand all concepts of datus that are needed to make use of its whole feature set.

Always consider if everyone on your team fulfills the outlined prerequisites before deciding to use datus for your project.

Getting started

datus is available at Maven Central:

<dependency>
  <groupId>com.github.roookeee</groupId>
  <artifactId>datus</artifactId>
  <version>1.5.0</version>
</dependency>

Maven Central

datus supports any JDK (Hotspot, OpenJDK, J9 etc.) which supports Java 8 or newer.

Basics

datus core workflow revolves around the Datus and Mapper<Input,Output> classes.

The Datus class serves as the main entry point for starting a mapping definition and provides both the immutable and mutable API:

Datus.forTypes(Person.class, PersonDTO.class)

The first step is to define which input and output type datus has to consider. Now it is time to choose the immutable or mutable API of datus:

Datus.forTypes(Person.class, PersonDTO.class).mutable(PersonDTO::new)
Datus.forTypes(Person.class, PersonDTO.class).immutable(PersonDTO::new)

The mutable API expects the passed function (usually a constructor) to have exactly zero constructor parameters whereas the immutable API supports up to 12 constructor parameters (consider opening an issue if you need datus to support more constructor parameters). datus unifies the experience of both APIs as much as possible which makes understanding the core concepts of datus easier as well.

Fundamentally both the immutable and mutable API define their mapping steps on a field-by-field/parameter-by-parameter basis:

.from(InputType::someGetter)
.map(Logic::someProcessing)
.given(Object::nonNull, String::trim).orElse("fallback")
.to(OutputType::someSetter OR ConstructorParameter::bind)
.nullsafe()

from(Input::someGetter): The first step is to supply a data source from which the current mapping step receives its data. This data source is naturally related to the input type and most likely a simple getter (InputType::someGetter here).

map(Logic::someProcessing): Mapping the value of the datasource is purely optional and can be chained as much as needed. map in datus is similar to Optional.map or Stream.map and may change the type of the mapping step as needed.

given(Object::nonNull, String::trim).orElse("fallback"): Like map, using given is entirely optional. given is similar to map but considers a given predicate to determine which function/supplier/value to use. As the type of the current mapping step may change through the provided function/supplier/value in the given-call an orElse is mandatory to ensure both branches result in the same type (if the type does not change consider using Function.identity() in cases where one branch should not modify the value in any way).

to(OutputType::someSetter OR ConstructorParameter::bind): The to/into operations of datus finalize the preceding mapping step definition by binding its definition to the current constructor parameter (immutable API) or a given setter (mutable API). Any type conversion (e.g. an Address field in Person has to be transformed to an AddressDTO for the PersonDTO) has to happen in preceding map steps. A type mismatch will always result in a compilation error.

nullsafe(): nullsafe() enables null safety for the current mapping step (one from()...to() chain) - null inputs will bypass subsequent mapping parts (map and given declarations) and will be directly passed to the destination (to()).

Once all necessary mapping steps are completed, calling build() will finalize the mapping definition and generate a Mapper<Input, Output> object. Most features of the Mapper interface are about the conversion from input to output:

interface Mapper<Input, Output> {
    //the only function that is actually implemented by the given mapping steps 
    //all other functions are based on it:
    Output convert(Input input);
    List<Output> convert(Collection<Input> input);
    Stream<Output> conversionStream(Collection<Input> input);
    
    Map<Input, Output> convert(Collection<Input> input);
    Map<MapKeyType, Output> convert(Collection<Input> input, Function<Input, MapKeyType> keyMapper);
}

Other functions allow predicating a given Mapper<Input, Output> in regards to the input object (e.g. input must not be null), the generated output (e.g. some business logic validation) object or both:

interface Mapper<Input, Output> {
    // omitting the above functions for brevity
    Mapper<Input, Optional<Output>> predicateInput(Predicate<Input> predicate);
    Mapper<Input, Optional<Output>> predicateOutput(Predicate<Output> predicate);
    Mapper<Input, Optional<Output>> predicate(Predicate<Input> inputPredicate, Predicate<Output> outputPredicate);
}

Both the immutable and mutable API are statically type checked and thus won't compile if an invalid mapping definition is given (e.g. type mismatches).

That's it for the basic introduction of datus and its workflow. One last thing: datus is about mapping an input object to an output object, thus it is strongly discouraged to change the input object in any way when defining a mapping process in one of datus APIs.

Immutable API

The immutable API of datus works by defining the mapping process of each constructor parameter in the order they are declared in the given constructor. Every .from(...).(...).to(ConstructorParameter::bind) definition automatically moves to the next constructor parameter until every constructor parameter is bound to a mapping process.

datus immutable API provides additional functionality once every constructor parameter received its mapping process:

spy(BiConsumer<In, Out>): spy is used to notify a given function about a successfully applied mapping process. The main use case of spy is logging or other cross-cutting concerns. It is strongly discouraged to change the input object in any way.

process(BiFunction<Input, Output, Output>: process enables additional post-processing after a given input object has been converted to an output object. process should only be used when other facilities of datus won't suffice or become too verbose. It is strongly discouraged to change the input object in any way.

Finally, a build()-call finishes the mapping process definition by generating a Mapper<Input,Output> which internally uses all preceding mapping definitions.

Mutable API

The mutable API of datus works by defining a set of getter-setter chains. Every .from(...).(...).into(Output::someSetter) adds a mapping definition to the later generated Mapper<Input, Output>. There are no checks for exhaustiveness or duplicate mappings as there is no proper way to implement it (e.g. lambdas and/or function references have no reference equality guarantees in the Java specification).

The mutable API offers two terminal operations to finalize a given mapping definition - to and into. into accepts a simple setter on the output type whereas to accepts a function which creates a new instance of the output type when applying the setter. So why is to needed? Consider the following setter:

public Output setSomeStuff(String value) {
    return new Output(value);
}

to is needed/more elegant for cases in which setters return a new instance of the output type or for some reason return the object for chaining. This is especially useful for Kotlins data classes. into would not suffice in this context as it does not allow to replace the whole output object or support functions which have a return value.

datus mutable API provides additional functions which are not directly related to mapping a single value from the input to the output object:

spy(BiConsumer<In, Out>): spy is used to notify a given function about the current state of the input and output objects. The main use case of spy is logging or other cross-cutting concerns. Compared to the immutable API, spy can be inserted before and after every mapping process definition. It is strongly discouraged to change the input object in any way.

process(BiFunction<Input, Output, Output>: process enables additional post-processing of a given output object. process should only be used when other facilities of datus won't suffice or become too verbose. Compared to the immutable API, process can be inserted before and after every mapping process definition. Extensive use of process is discouraged as it is hard to reason about what fields of the output object are affected (e.g. maybe it overrides a field for which you have just defined a mapping process). It is recommended to only use process after all the getter-setter chains have been defined (which clearly signals some form of post-processing). It is strongly discouraged to change the input object in any way.

Finally, a build()-call finishes the mapping process definition by generating a Mapper<Input,Output> which internally uses all preceding mapping definitions.

Examples

This section shows some basic usage scenarios for datus and most of its features. The following two simple objects are the foundation of this section:

class Person {
    //constructor + getters omitted for brevity
    private final String firstName;
    private final String lastName;
    private final boolean active;
    private final boolean canLogin;
}

class PersonDTO {
    //getters omitted for brevity
    private final String firstName;
    private final String lastName;
    private final boolean active;
    private final boolean canLogin;
    
    public PersonDTO(String firstName, String lastName, boolean active, boolean canLogin) {
        this.firstName = firstName;
        this.lastName = lastName;
        this.active = active;
        this.canLogin = canLogin;
    }
}

The following examples focus' on the immutable API of datus but every ConstructorParameter::bind can be directly replaced by a setter on PersonDTO to accomplish the same task in the mutable API without changing anything else besides the initial .immutable(PersonDTO::new)-call and using into instead of to (see here as to why this is).

Let's start with a simple copying mapper and some predicated variations:

class MapperDefinitions {
    private Mapper<Person, PersonDTO> mapper = Datus.forTypes(Person.class, PersonDTO.class)
        .immutable(PersonDTO::new)
        .from(Person::getFirstName).to(ConstructorParameter::bind)
        .from(Person::getLastName).to(ConstructorParameter::bind)
        .from(Person::isActive).to(ConstructorParameter::bind)
        .from(Person::isCanLogin).to(ConstructorParameter::bind)
        .build();
    
    //let's not try to convert null inputs
    private Mapper<Person, Optional<PersonDTO>> inputCheckedMapper = 
        mapper.predicateInput(Object::nonNull);
    
    //let's not try to convert null inputs and only output active users
    private Mapper<Person, Optional<PersonDTO>> onlyActiveResults = 
        mapper.predicate(Object::nonNull, PersonDTO::isActive);
}

Let's assume a PersonDTO's canLogin respects the Person's isActive flag and a Person's firstName and lastName may contain unnecessary whitespaces that need to be trimmed:

class MapperDefinitions {
    private Mapper<Person, PersonDTO> mapper = Datus.forTypes(Person.class, PersonDTO.class)
        .immutable(PersonDTO::new)
        .from(Person::getFirstName).map(String::trim).to(ConstructorParameter::bind)
        .from(Person::getLastName).map(String::trim).to(ConstructorParameter::bind)
        .from(Person::isActive).to(ConstructorParameter::bind)
        .from((Function.identity())
            .map(person -> person.isActive() && person.isCanLogin())
            .to(ConstructorParameter::bind)
        .build();
}

Maybe some parts of the mapping logic are businessful or too complex to express in a simple lambda:

class PersonNameCleaner {
    public String cleanupFirstName(String firstName) { ... }
    public String cleanupLastName(String firstName) { ... }
}

class PersonValidator {
    public boolean shouldBeActive(Person person) { ... }
}

class MapperDefinitions {
    //maybe get these instances via dependency injection 
    //or a parameter when using a function to generate the mapper
    private PersonNameCleaner personNameCleaner = new PersonNameCleaner();
    private PersonValidator personValidator = new PersonValidator();
    
    private Mapper<Person, PersonDTO> mapper = Datus.forTypes(Person.class, PersonDTO.class)
        .immutable(PersonDTO::new)
        .from(Person::getFirstName).map(personNameCleaner::cleanupFirstName).to(ConstructorParameter::bind)
        .from(Person::getLastName).map(personNameCleaner::cleanupLastName).to(ConstructorParameter::bind)
        .from(Function.identity()).map(personValidator::shouldBeActive).to(ConstructorParameter::bind)
        .from(Function.identity())
            .map(person -> personValidator.shouldBeActive(person) && person.isCanLogin())
            .to(ConstructorParameter::bind)
        .build();
}

Some changes were done and the Person's firstName and lastName are now nullable, let's integrate that before we pass null to the functions of PersonNameCleaner:

class PersonNameCleaner {
    public String cleanupFirstName(String firstName) { ... }
    public String cleanupLastName(String firstName) { ... }
}

class PersonValidator {
    public boolean shouldBeActive(Person person) { ... }
}

class MapperDefinitions {
    //maybe get these instances via dependency injection
    //or a parameter when using a function to generate the mapper
    private PersonNameCleaner personNameCleaner = new PersonNameCleaner();
    private PersonValidator personValidator = new PersonValidator();
    
    private Mapper<Person, PersonDTO> mapper = Datus.forTypes(Person.class, PersonDTO.class)
        .immutable(PersonDTO::new)
        .from(Person::getFirstName).nullsafe().map(personNameCleaner::cleanupFirstName)
            .to(ConstructorParameter::bind)
        .from(Person::getLastName).nullsafe().map(personNameCleaner::cleanupLastName)
            .to(ConstructorParameter::bind)
        .from(Function.identity()).map(personValidator::shouldBeActive).to(ConstructorParameter::bind)
        .from(Function.identity())
            .map(person -> personValidator.shouldBeActive(person) && person.isCanLogin())
            .to(ConstructorParameter::bind)
        .build();
}

null is handled now but someone called you to "fix" every empty (empty string) firstName by setting it to "<missing>":

class PersonNameCleaner {
    public String cleanupFirstName(String firstName) { ... }
    public String cleanupLastName(String firstName) { ... }
}

class PersonValidator {
    public boolean shouldBeActive(Person person) { ... }
}

class MapperDefinitions {
    //maybe get these instances via dependency injection
    //or a parameter when using a function to generate the mapper
    private PersonNameCleaner personNameCleaner = new PersonNameCleaner();
    private PersonValidator personValidator = new PersonValidator();
    
    private Mapper<Person, PersonDTO> mapper = Datus.forTypes(Person.class, PersonDTO.class)
        .immutable(PersonDTO::new)
        .from(Person::getFirstName).nullsafe()
            .given(String::isEmpty, "<missing>").orElse(personNameCleaner::cleanupFirstName)
      //or: .given(StringUtils::isNotEmpty, personNameCleaner::cleanupFirstName).orElse("<missing>")
            .to(ConstructorParameter::bind)
        .from(Person::getLastName).nullsafe().map(personNameCleaner::cleanupLastName)
            .to(ConstructorParameter::bind)
        .from(Function.identity()).map(personValidator::shouldBeActive).to(ConstructorParameter::bind)
        .from(Function.identity())
            .map(person -> personValidator.shouldBeActive(person) && person.isCanLogin())
            .to(ConstructorParameter::bind)
        .build();
}

Great, we are done! That's it for this example.

Sample projects

There are two sample projects located in the sample-projects directory that showcase most of datus features in two environments: framework-less and with Spring Boot.

Hop right in and tinker around with datus in a compiling environment!

Configuration options

This section describes all configuration options of datus which are generally controlled by system properties.

Optimizations

datus employs several runtime-optimizations that can be partially disabled by setting the system property com.github.roookeee.datus.optimization.disable to any non-null value. There are currently no known issues regarding these optimizations but as always: any feature that cannot be disabled is a bug.

Some basic optimizations are not affected by this system property as them not functioning correctly would imply a broken JVM implementation on an unrecoverable level for not only datus but any other code running in it.

Generally speaking: any error related to datus working with com.github.roookeee.datus.optimization.disable set but not working without it is a bug - please file an issue!

Advanced usage / FAQ

This section is focused on use cases of datus that are either not directly supported via datus classes, unintuitive or represent a question that is frequently asked in datus issue tracker.

Generic input and / or output types

datus Datus.forTypes(InputType.class, OutputType.class) function does not support generic types like List<String> as obtaining a List<String>.class is not possible in Java. You can use the Datus.<InputType, OutputType>forTypes() overload which requires you to specify the types in angled brackets to circumvent this issue:

Datus.<List<Integer>,List<String>>forTypes()
   .mutable(...)

This overload is also usable in a non-generic context like Datus.<Integer,String>forTypes(). Which forType() overload you choose mostly comes down to personal taste.

See the GenericsTest.java for an example for both the mutable and immutable API.

Mapping multiple inputs into one output object

datus by design only supports mapping one input object into one output object. Converting input objects sometimes requires additional information on a per input object basis which makes using datus for these kind of conversions unpleasant, badly performing or even impossible.

The best way to handle multiple input objects when using datus is to use Pair<Input1, Input2>, Triple<Input1, Input2, Input3> (, ...) alike container objects. Please note that datus does not supply these container types as these kind of objects lie outside of datus scope of mapping from an input to an output object. Feel free to use any other library which implement said container types but please reconsider if datus is really the right solution for aggregating multiple input objects into one output object as these kind of mappings often imply a great amount of logic which might justify implementing it in a standalone factory class.

I cannot express X with the immutable / mutable API (but want consistency by using the Mapper<Input,Output> interface)

(Preamble: Feel free to open an issue if you think datus could be improved or extended)

Some mapping scenarios are cumbersome or even impossible to express in datus immutable / mutable API. This is where the simplistic Mapper<Input, Output> interface comes in handy. It only requires one function, Output convert(Input input), to provide all the mapper functionality as outlined at the end of basics.

Here is a simple list copying mapper for example:

Mapper<List<String>, List<String>> copyMapper = list -> new ArrayList<>(list)

You can always implement the Mapper<Input, Output> interface to gain a more consistent usage of factories / converters across your library / application which would also allow for a more sophisticated implementation of e.g. Collection<Output> convert(Collection<Input>) (e.g. batch some operations to a helper class).

Mapping recursive data structures

Consider the following class:

class Node {
    //getter + setter omitted for brevity
    private Node parent;
    private Object someData;
}

At first glance it seems like it is impossible to define a mapping process for such a self-recursive data structure as the Mapper<Anything, Node> cannot be referenced while still under construction: the java compiler will complain about referencing an uninitialized variable. But there is a way to use datus immutable / mutable API to generate a mapper for self-recursive data structures by using a helper class - MapperProxy:

public Mapper<Node, Node> generateMapper() {
    MapperProxy<Node, Node> proxy = new MapperProxy<>();
    Mapper<Node, Node> mapper = Datus.forTypes(Node.class, Node.class)
        .mutable(Node::new)
        .from(Node::someData).to(Node::setSomeData)
        .from(Node::getParent).nullsafe() //break recursion on null values
            .map(proxy::convert)
            .to(Node::setParent)
        .build();
    proxy.setMapper(mapper);
    return mapper;
}

A MapperProxy implements the Mapper interface by using another mapper which can be set even after the MapperProxy is instantiated and referenced which circumvents the outlined restrictions of the Java compiler.

Dependency injection (e.g. Spring)

datus has no explicit code to support dependency injection and its accompanying concepts but is easily integrated into any dependency injection framework (e.g Spring):

@Configuration
public class MapperConfiguration {
    @Bean
    public Mapper<Person, PersonDTO> generatePersonMapper() {
        return Datus.forTypes(Person.class, PersonDTO.class).mutable(PersonDTO::new)
            .from(Person::getFirstName).into(PersonDTO.setFirstName)
            .build();
    } 
}

@Component
public class SomeClass {
    
    private final Mapper<Person, PersonDTO> personMapper;
    
    @Autowired
    public SomeClass(Mapper<Person, PersonDTO> personMapper) {
        this.personMapper = personMapper;
    }
}

Drawbacks and when not to use datus

datus is an abstraction layer which like all of its kind (e.g. guava, Spring etc.) comes at a certain performance cost that in some scenarios will not justify the outlined benefits of using datus. datus is rigorously profiled while developing its features which results in the following advice:

If you map a massive amount of objects (> 40000 objects / ms (millisecond) per thread on an i7 6700k) whilst not having any computationally significant .map-steps you will suffer a significant performance loss compared to a traditional factory with imperative style mapping code. The performance cost of using the immutable / mutable API of datus will probably decrease over time as the JVM is getting more optimized in regards to handling code which relies heavily on functional programming concepts.

But remember: you can always implement performance critical conversion factories as standalone classes that implement the Mapper<Input, Output> interface to alleviate the performance hit while retaining consistency across your project.

Closing words

Congratulations - you have just mastered all the basics of datus!

Feel free to create an issue if something is missing in datus documentation or its implementation. Thank you for reading the usage guide.

Like datus? Consider buying me a coffee :)

Buy Me A Coffee