Friday, November 13, 2020

Using Java 14 Records in JSF via Eclipse

Introduction

Java 14 introduced the record type. It's basically some sort of an immutable JavaBean without the need to write/generate all these accessor/equals/hashCode/toString methods.

Summarized, the following record in its full form:

public record Person(
    Long id,
    String email,
    LocalDate dateOfBirth
) {}

Corresponds less or more to the following class:

public class Person {
    private final Long id;
    private final String email;
    private final LocalDate dateOfBirth;
    
    public Person(
        Long id,
        String email,
        LocalDate dateOfBirth
    ) {
        this.id = id;
        this.email = email;
        this.dateOfBirth = dateOfBirth;
    }
    
    public Long id() {
        return id;
    }
    
    public String email() {
        return email;
    }
    
    public LocalDate dateOfBirth() {
        return dateOfBirth;
    }
    
    @Override
    public boolean equals(Object other) {
        return other == this || other instanceof Person
            && Objects.equals(id, ((Person) other).id)
            && Objects.equals(email, ((Person) other).email)
            && Objects.equals(dateOfBirth, ((Person) other).dateOfBirth);
    }
    
    @Override
    public int hashCode() {
        return Objects.hash(
            id,
            email,
            dateOfBirth
        );
    }
    
    @Override
    public String toString() {
        return getClass().getSimpleName() + "["
            + "id=" + id + ", "
            + "email=" + email + ", "
            + "dateOfBirth=" + dateOfBirth
        + "]";
    }
}

But then without all the repeated boilerplate :)

It almost matches the JavaBeans Spec, with the exception that the method names of the accessor methods are not prefixed with get/is.

Preparing Eclipse for Java 14 Records

Being able to develop Java 14 Records in Eclipse requires a minimum of Eclipse 2020-06, because Java 14 support was only introduced in that version for the first time. In Eclipse's preferences, you need to ensure that Java ➤ Compiler ➤ Compiler compliance level is set to at least 14.

If you're using Maven, then you need to ensure that the pom.xml properly instructs Maven to use Java 14 as well. Assuming that you've a Maven project which is created the same way as instructed in JSF 2.3 tutorial with Eclipse, Maven, WildFly and H2, then you just need to adjust the maven.compiler.* properties as below:

<properties>
    ...
    <maven.compiler.source>14</maven.compiler.source>
    <maven.compiler.target>14</maven.compiler.target>
    ...
</properties>

And then right-click the project in Project Explorer view and choose Maven ➤ Update Project. It will automatically sort out the Java version set in Project Facets based on the above setting in pom.xml.

However, this is not sufficient. If you attempt to create a record via the New Java Record wizard as available by right-clicking the project and choosing New ➤ Record, and fill out the fields, then Eclipse will error out as:

Project 'projectname' does not have preview features enabled.

Or when you manually create a class representing a record without using the wizard, then Eclipse will still error out as:

Type record is a preview feature and disabled by default. Use --enable-preview to enable

Indeed, the Java 14 Records feature is only available as a so-called "preview feature". This means that it's by default disabled. It's only available when you explicitly pass --enable-preview argument to the java/javac command.

This can be done in Eclipse via Java Compiler settings in project-specific properties or in window-global properties. The one in project-specific properties will be overridden every time you run Maven Update. It's better to configure it in window-global properties. Open Window ➤ Preferences ➤ Java ➤ Compiler. In the Compiler section, uncheck the Use default compliance settings option and then check the Enable preview features for Java 14 option.

Apply and close.

Creating and using Java Records in JSF

Once having configured the Eclipse project to use Java Records as instructed in previous chapter, then creating a new record will not anymore error out in Eclipse.

public record Person(
    Long id,
    String email,
    LocalDate dateOfBirth
) {}

It will only still show a warning like below:

You are using a preview language feature that may or may not be supported in a future release

It basically boils down that Java Records in its current form is not guaranteed to be backwards compatible in a newer Java version. For example, in a future Java version, the accessor methods might be adjusted to follow JavaBeans Spec to have the get or is method name prefix. If this is going to happen, then any calls to person.id() in existing code will not compile anymore and should be adjusted to person.getId(). Or perhaps the feature will even be completely removed. This potential tech debt has just to be carefully taken into account.

This is okay for now.

Now, let's create an example JSF bean which uses these records:

@Named
@RequestScoped
public class Bean {

    private List<Person> persons;
	
    @PostConstruct
    public void init() { 
        persons = new ArrayList<>();
        persons.add(new Person(1L, "john.doe@example.com", LocalDate.of(1978, 3, 26)));
        persons.add(new Person(2L, "jane.doe@example.com", LocalDate.of(1980, 10, 31)));
        persons.add(new Person(3L, "joe.bloggs@example.com", LocalDate.of(2002, 10, 5)));
    }
	
    public List<Person> getPersons() {
        return persons;
    }
}

And a XHTML file which just iterates over this list and prints each item plain:

<ui:repeat value="#{bean.persons}" var="person">
    #{person}
    <br/>
</ui:repeat>

After deploying it to a Jakarta EE server such as WildFly 21.0.0, you will face the following warning in server logs and the #{bean} will not be available in EL (newlines introduced for readability):

WARN [org.jboss.modules.define] (Weld Thread Pool -- 1)
    Failed to define class com.example.Bean in Module "deployment.playground-wildfly-0.0.1-SNAPSHOT.war" from Service Module Loader:
    java.lang.UnsupportedClassVersionError:
        Failed to link com/example/Bean (Module "deployment.playground-wildfly-0.0.1-SNAPSHOT.war" from Service Module Loader): 
        Preview features are not enabled for com/example/Bean (class file version 58.65535). 
        Try running with '--enable-preview'
    at java.base/java.lang.ClassLoader.defineClass1(Native Method)
    at java.base/java.lang.ClassLoader.defineClass(ClassLoader.java:1017)
    at java.base/java.lang.ClassLoader.defineClass(ClassLoader.java:1096)
    at org.jboss.modules.ModuleClassLoader.doDefineOrLoadClass(ModuleClassLoader.java:424)
    at org.jboss.modules.ModuleClassLoader.defineClass(ModuleClassLoader.java:555)
    at org.jboss.modules.ModuleClassLoader.loadClassLocal(ModuleClassLoader.java:339)
    at org.jboss.modules.ModuleClassLoader$1.loadClassLocal(ModuleClassLoader.java:126)
    at org.jboss.modules.Module.loadModuleClass(Module.java:731)
    at org.jboss.modules.ModuleClassLoader.findClass(ModuleClassLoader.java:247)
    at org.jboss.modules.ConcurrentClassLoader.performLoadClassUnchecked(ConcurrentClassLoader.java:410)
    at org.jboss.modules.ConcurrentClassLoader.performLoadClass(ConcurrentClassLoader.java:398)
    at org.jboss.modules.ConcurrentClassLoader.loadClass(ConcurrentClassLoader.java:116)
    at org.jboss.as.weld@21.0.0.Final//org.jboss.as.weld.WeldModuleResourceLoader.classForName(WeldModuleResourceLoader.java:68)
    at org.jboss.weld.core@3.1.5.Final//org.jboss.weld.bootstrap.AnnotatedTypeLoader.loadClass(AnnotatedTypeLoader.java:82)
    at org.jboss.weld.core@3.1.5.Final//org.jboss.weld.bootstrap.FastAnnotatedTypeLoader.createContext(FastAnnotatedTypeLoader.java:114)
    at org.jboss.weld.core@3.1.5.Final//org.jboss.weld.bootstrap.FastAnnotatedTypeLoader.loadAnnotatedType(FastAnnotatedTypeLoader.java:103)
    at org.jboss.weld.core@3.1.5.Final//org.jboss.weld.bootstrap.BeanDeployer.addClass(BeanDeployer.java:87)
    at org.jboss.weld.core@3.1.5.Final//org.jboss.weld.bootstrap.ConcurrentBeanDeployer$1.doWork(ConcurrentBeanDeployer.java:55)
    at org.jboss.weld.core@3.1.5.Final//org.jboss.weld.bootstrap.ConcurrentBeanDeployer$1.doWork(ConcurrentBeanDeployer.java:52)
    at org.jboss.weld.core@3.1.5.Final//org.jboss.weld.executor.IterativeWorkerTaskFactory$1.call(IterativeWorkerTaskFactory.java:62)
    at org.jboss.weld.core@3.1.5.Final//org.jboss.weld.executor.IterativeWorkerTaskFactory$1.call(IterativeWorkerTaskFactory.java:55)
    at java.base/java.util.concurrent.FutureTask.run(FutureTask.java:264)
    at java.base/java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1130)
    at java.base/java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:630)
    at java.base/java.lang.Thread.run(Thread.java:832)
    at org.jboss.threads@2.4.0.Final//org.jboss.threads.JBossThread.run(JBossThread.java:513)

Note specifically the hint Try running with '--enable-preview'. That's indeed what we need to do.

Doubleclick the WildFly server in Servers view and then click the Open launch configuration link. In the VM arguments field, add the new VM argument --enable-preview as highlighted in the below screenshot:

Restart the server. The warning in server logs is now gone. Opening the example XHTML file will produce this result:

Accessing Java Record "properties" in EL

Trouble will start when you adjust the XHTML to access the "properties" of the Java Record in EL as if they were JavaBean properties:

<h:dataTable value="#{bean.persons}" var="person">
    <h:column>#{person.id}</h:column>
    <h:column>#{person.email}</h:column>
    <h:column>#{person.dateOfBirth}</h:column>
</h:dataTable>

It will throw an exception with the following root cause:

javax.el.ELException: /test.xhtml: The class 'com.example.Person' does not have the property 'id'.
    at com.sun.jsf-impl@2.3.14.SP01//com.sun.faces.facelets.compiler.TextInstruction.write(TextInstruction.java:47)
    at com.sun.jsf-impl@2.3.14.SP01//com.sun.faces.facelets.compiler.UIInstructions.encodeBegin(UIInstructions.java:41)
    at com.sun.jsf-impl@2.3.14.SP01//com.sun.faces.renderkit.html_basic.HtmlBasicRenderer.encodeRecursive(HtmlBasicRenderer.java:276)
    at com.sun.jsf-impl@2.3.14.SP01//com.sun.faces.renderkit.html_basic.TableRenderer.renderRow(TableRenderer.java:374)
    at com.sun.jsf-impl@2.3.14.SP01//com.sun.faces.renderkit.html_basic.TableRenderer.encodeChildren(TableRenderer.java:137)
    at javax.faces.api@3.0.0.SP04//javax.faces.component.UIComponentBase.encodeChildren(UIComponentBase.java:566)
    at javax.faces.api@3.0.0.SP04//javax.faces.component.UIComponent.encodeAll(UIComponent.java:1647)
    at javax.faces.api@3.0.0.SP04//javax.faces.component.UIComponent.encodeAll(UIComponent.java:1650)
    at javax.faces.api@3.0.0.SP04//javax.faces.component.UIComponent.encodeAll(UIComponent.java:1650)
    at com.sun.jsf-impl@2.3.14.SP01//com.sun.faces.application.view.FaceletViewHandlingStrategy.renderView(FaceletViewHandlingStrategy.java:468)
    at com.sun.jsf-impl@2.3.14.SP01//com.sun.faces.application.view.MultiViewHandler.renderView(MultiViewHandler.java:170)
    at javax.faces.api@3.0.0.SP04//javax.faces.application.ViewHandlerWrapper.renderView(ViewHandlerWrapper.java:132)
    at javax.faces.api@3.0.0.SP04//javax.faces.application.ViewHandlerWrapper.renderView(ViewHandlerWrapper.java:132)
    at com.sun.jsf-impl@2.3.14.SP01//com.sun.faces.lifecycle.RenderResponsePhase.execute(RenderResponsePhase.java:102)
    at com.sun.jsf-impl@2.3.14.SP01//com.sun.faces.lifecycle.Phase.doPhase(Phase.java:76)
    at com.sun.jsf-impl@2.3.14.SP01//com.sun.faces.lifecycle.LifecycleImpl.render(LifecycleImpl.java:199)
    at javax.faces.api@3.0.0.SP04//javax.faces.webapp.FacesServlet.executeLifecyle(FacesServlet.java:708)
    ... 51 more

What is happening here? Well, EL only understands JavaBeans Spec and the Java Records do not exactly comply the JavaBeans Spec. EL is basically trying to find the getId() method on the record, but there's only the id() method. Hence EL thinks it does not exist. Moreover, in case of Java Records we shouldn't really talk about "properties", but about "fields".

Adjusting EL expressions from the #{bean.property} syntax to the #{record.accessor()} syntax as below:

<h:dataTable value="#{bean.persons}" var="person">
    <h:column>#{person.id()}</h:column>
    <h:column>#{person.email()}</h:column>
    <h:column>#{person.dateOfBirth()}</h:column>
</h:dataTable>

Will make it work in some EL implementations.

It worked for me in WildFly 21, but it has been reported to not work in other EL implementations.

Resolving Java Records as JavaBeans in EL

In case you want to be able to keep using the #{bean.property} syntax to access fields of Java Records, then you can always create a custom ELResolver which detects Java Records and resolves the EL expressions accordingly. This will be very useful when you intend to share some XHTML templates among beans and records with the same properties.

Detecting whether a given class is a Java Record can be done by checking Class#isRecord(). Obtaining all the record components associated with the record class can be done by Class#getRecordComponents(). Given this information, the following custom ELResolver which basically converts record fields to bean properties should do it:

public class RecordELResolver extends ELResolver {

    private static final Map<Class<?>, Map<String, PropertyDescriptor>> RECORD_PROPERTY_DESCRIPTOR_CACHE = new ConcurrentHashMap<>();

    private static boolean isRecord(Object base) {
        return base != null && base.getClass().isRecord();
    }
    
    private static Map<String, PropertyDescriptor> getRecordPropertyDescriptors(Object base) {
        return RECORD_PROPERTY_DESCRIPTOR_CACHE
            .computeIfAbsent(base.getClass(), clazz -> Arrays
                .stream(clazz.getRecordComponents())
                .collect(Collectors
                    .toMap(RecordComponent::getName, recordComponent -> {
                        try {
                            return new PropertyDescriptor(recordComponent.getName(), recordComponent.getAccessor(), null);
                        }
                        catch (IntrospectionException e) {
                            throw new IllegalStateException(e);
                        }
                    })));
    }
    
    private static PropertyDescriptor getRecordPropertyDescriptor(Object base, Object property) {
        PropertyDescriptor descriptor = getRecordPropertyDescriptors(base).get(property);
        
        if (descriptor == null) {
            throw new PropertyNotFoundException("The record '" + base.getClass().getName() + "' does not have the field '" + property + "'.");
        }

        return descriptor;
    }

    @Override
    public Object getValue(ELContext context, Object base, Object property) {
        if (!isRecord(base) || property == null) {
            return null;
        }

        PropertyDescriptor descriptor = getRecordPropertyDescriptor(base, property);

        try {
            Object value = descriptor.getReadMethod().invoke(base);
            context.setPropertyResolved(base, property);
            return value;
        }
        catch (Exception e) {
            throw new ELException(e);
        }
    }

    @Override
    public Class<?> getType(ELContext context, Object base, Object property) {
        if (!isRecord(base) || property == null) {
            return null;
        }

        PropertyDescriptor descriptor = getRecordPropertyDescriptor(base, property);
        context.setPropertyResolved(true);
        return descriptor.getPropertyType();
    }

    @Override
    public Class<?> getCommonPropertyType(ELContext context, Object base) {
        if (!isRecord(base)) {
            return null;
        }

        return String.class;
    }

    @Override
    public boolean isReadOnly(ELContext context, Object base, Object property) {
        if (!isRecord(base)) {
            return false;
        }

        getRecordPropertyDescriptor(base, property); // Forces PropertyNotFoundException if necessary.
        context.setPropertyResolved(true);
        return true; // Java Records are per definition immutable.
    }

    @Override
    public void setValue(ELContext context, Object base, Object property, Object value) {
        if (!isRecord(base)) {
            return;
        }

        getRecordPropertyDescriptor(base, property); // Forces PropertyNotFoundException if necessary.
        throw new PropertyNotWritableException("Java Records are immutable");
    }

    @Override
    public Iterator<FeatureDescriptor> getFeatureDescriptors(ELContext context, Object base) {
        if (!isRecord(base)) {
            return null;
        }

        Map rawDescriptors = getRecordPropertyDescriptors(base);
        return rawDescriptors.values().iterator();
    }
}

In order to get it to run, register as below in faces-config.xml:

<application>
    <el-resolver>com.example.RecordELResolver</el-resolver>
</application>

Now you can successfully access record fields in EL as if they were bean properties:

<h:dataTable value="#{bean.persons}" var="person">
    <h:column>#{person.id}</h:column>
    <h:column>#{person.email}</h:column>
    <h:column>#{person.dateOfBirth}</h:column>
</h:dataTable>

Using Java Records as JPA Entities

Unfortunately, due to the immutable nature of Java Records, they are not at all useful as JPA entities. But they are definitely useful as DTOs. This has been blogged by Thorben Janssen and Vlad Mihalcea before.

Imagine that you've an entity with "too many" properties wihch would only unnecessarily select every single column when using SELECT p FROM Person p in JPA:

@Entity
public class Person {

    @Id
    private Long id;
    
    @Column
    private String email;
    
    @Column
    private boolean emailVerified;
    
    @Column
    private String firstName;
    
    @Column
    private String lastName;
    
    @Column
    private LocalDate dateOfBirth;
    
    @Column
    private String phoneNumber;
    
    @Enumerated(STRING)
    private Status status;

    @OneToOne(fetch = LAZY)
    private Image profileImage;
    
    @ManyToOne(fetch = LAZY)
    private Address homeAddress;

    @OneToMany(mappedBy = "person", fetch = LAZY)
    private Set<LoginToken> loginTokens;

    @ElementCollection(fetch = EAGER) @Enumerated(STRING)
    private Set<Group> groups;

    // etc ...
}

Then you can use a Java Record as DTO to select only a specific subset of columns, such as ID, first name, last name and profile image in order to present it in some list of cards:

public record PersonCard(
    Long id,
    String firstName,
    String lastName,
    Image profileImage
) {}

Then you can use the constructor expression in JPQL query as demonstrated in the listCards() method below:

@Stateless
public class PersonService {

    @TransactionAttribute(NOT_SUPPORTED)
    public List<Person> listAll() {
        return entityManager.createQuery(
            """
                SELECT 
                    p
                FROM
                    Person p
            """
            , Person.class)
            .getResultList();
    }

    @TransactionAttribute(NOT_SUPPORTED)
    public List<PersonCard> listCards() {
        return entityManager.createQuery(
            """
                SELECT 
                    new com.example.PersonCard(
                        p.id,
                        p.firstName,
                        p.lastName,
                        pi
                    )
                FROM
                    Person p
                        LEFT JOIN FETCH
                    p.profileImage pi
            """
            , PersonCard.class)
            .getResultList();
    }
}

Notice the Text Blocks syntax """ ... """, which is yet another preview feature, but then already introduced in Java 13. This saves you from fiddling with string-concatenating the newlines in JPQL queries which would only produce barely manageable JPQL queries in Java source code.

This shall work just fine in JSF. For example:

@Named
@RequestScoped
public class Bean {

    private List<PersonCard> personCards;

    @Inject
    private PersonService personService;

    @PostConstruct
    public void init() { 
        personCards = personService.listCards();
    }
	
    public List<PersonCard> getPersonCards() {
        return personCards;
    }
}
<ul>
    <ui:repeat value="#{bean.personCards}" var="personCard">
        <li>
            <a href="person.xhtml?id=#{personCard.id}">
                <img src="#{personCard.profileImage.url}" />
                #{personCard.firstName} #{personCard.lastName}
            </a>
        </li>
    </ui:repeat>
</ul>

That's all, folks :) Happy coding!

No comments: