Search code examples
jsfeljava-record

Java Records to be used as JSF values


I am working on a simple JSF web application(mostly for learning) using Java 15 and JSF 2.3 plus PrimeFaces 8 and I am using a simple java record for modelling an entity. The application does not use any database.

My question is if it is possible to use the java records as values in the xhtml pages like this

    <p:column headerText="Id">
        <h:outputText value="#{car.randomId}" />
    </p:column>

becasue I am receiving the following error javax.el.PropertyNotFoundException: The class 'com.company.Car' does not have the property 'id'.. I tried putting car.year(), but it did not work. The definition of the record is this

public record Car (String randomId, String randomBrand, int randomYear, String randomColor, int randomPrice, boolean randomSoldState) {
}

In the pom.xml, I am using the following api

        <dependency>
            <groupId>jakarta.platform</groupId>
            <artifactId>jakarta.jakartaee-api</artifactId>
            <version>8.0.0</version>
            <scope>provided</scope>
        </dependency>

Thank you for your help!


Solution

  • Technically speaking, the issue is not in JSF, but in EL. The package name of the exception already hints this: javax.el.PropertyNotFoundException. The EL version being used doesn't recognize Java Records yet. Jakarta EE 8 ties with Java 8, but the Java Records feature was introduced in Java 14. Theoretically, it would at earliest only be natively supported in an EL version associated with a Jakarta EE version associated with Java 14. But even that is very unlikely because Java Records is only available as a "preview feature" (and thus not by default enabled at all).

    Coming back to your concrete problem, using the method expression syntax as in #{car.randomId()} did actually work for me on WildFly 21. In any case, EL resolving can be always customized with a custom ELResolver. Here's a kickoff example which checks for records based on Class#isRecord() and collects fields available via Class#getRecordComponents() as properties:

    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;
        }
    
        @Override
        public void setValue(ELContext context, Object base, Object property, Object value) {
            if (!isRecord(base)) {
                return;
            }
    
            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 work, register it in faces-config.xml as below:

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

    The real work is done in getValue() method. It basically locates the java.lang.reflect.Method representing the accessor of the Java Record and invokes it.

    That said, a Java Record is insuitable to substitute a fullworthy JavaBean, primarily because Java Records are immutable. So they cannot be used as real (JPA) entities because these are supposed to be mutable. Java Records are at most useful as read-only DTOs.