Search code examples
jsfbean-validationcomposite-component

How to bean-validate a collection property in a jsf composite component, constraints do not fire


How do I define a jsf composite component properly such that its value gets bean-validated correctly in the case it contains a collection?

We have an entity that references a collection of details. Both are annotated with bean-validation-constraints. Please note the annotations at the details-property.

public class Entity implements Serializable {

    @NotEmpty
    private String name;

    @NotEmpty
    @UniqueCategory(message="category must be unique")    
    private List<@Valid Detail> details;

    /* getters/setters */
}

public class Detail implements Serializable {

    @Pattern(regexp="^[A-Z]+$")
    private String text;

    @NotNull
    private Category category;

    /* getters/setters */
}

public class Category implements Serializable {

    private final int id;
    private final String description;

    Category(int id, String description) {
        this.id = id;
        this.description = description;
    }

    /* getters/setters */
}

public class MyConstraints {

    @Target({ ElementType.TYPE, ElementType.FIELD })
    @Retention(RetentionPolicy.RUNTIME)
    @Constraint(validatedBy = UniqueCategoryValidator.class)
    @Documented
    public static @interface UniqueCategory {
        String message();

        Class<?>[] groups() default {};

        Class<? extends Payload>[] payload() default {};
    }

    public static class UniqueCategoryValidator implements ConstraintValidator<UniqueCategory, Collection<Detail>> {

        @Override
        public boolean isValid(Collection<Detail> collection, ConstraintValidatorContext context) {
            if ( collection==null || collection.isEmpty() ) {
                return true;
            }
            Set<Category> set = new HashSet<>();
            collection.forEach( d-> set.add( d.getCategory() ));
            return set.size() == collection.size();
        }

        public void initialize(UniqueCategory constraintAnnotation) {
            // intentionally empty
        }
    }

    private MyConstraints() {
        // only static stuff
    }
}

The entity can be edited in a jsf-form, where all the tasks concerning the details are encapsulated in a composite component, eg

 <h:form id="entityForm">
    <h:panelGrid columns="3">
        <p:outputLabel for="@next" value="name"/>
        <p:inputText id="name" value="#{entityUiController.entity.name}"/>
        <p:message for="name"/>

        <p:outputLabel for="@next" value="details"/>
        <my:detailsComponent id="details" details="#{entityUiController.entity.details}"
            addAction="#{entityUiController.addAction}"/>
        <p:message for="details"/>

        <f:facet name="footer">
            <p:commandButton id="saveBtn" value="save"
                action="#{entityUiController.saveAction}"
                update="@form"/>
        </f:facet>
    </h:panelGrid>
</h:form>

where my:detailsComponent is defined as

<ui:component xmlns="http://www.w3.org/1999/xhtml"
    xmlns:h="http://java.sun.com/jsf/html"
    xmlns:f="http://java.sun.com/jsf/core"
    xmlns:ui="http://java.sun.com/jsf/facelets"
    xmlns:cc="http://java.sun.com/jsf/composite"
    xmlns:p="http://primefaces.org/ui"
    >

    <cc:interface>
        <cc:attribute name="details" required="true" type="java.lang.Iterable"/>
        <cc:attribute name="addAction" required="true" method-signature="void action()"/>
    </cc:interface>

    <cc:implementation>
        <p:outputPanel id="detailsPanel">
            <ui:repeat id="detailsContainer" var="detail" value="#{cc.attrs.details}">
                <p:inputText id="text" value="#{detail.text}" />
                <p:message for="text"/>
                <p:selectOneMenu id="category" value="#{detail.category}"
                    converter="#{entityUiController.categoriesConverter}"
                    placeholder="please select" >
                    <f:selectItem noSelectionOption="true" />
                    <f:selectItems value="#{entityUiController.categoryItems}"/>
                </p:selectOneMenu>
                <p:message for="category"/>
            </ui:repeat>
        </p:outputPanel>
        <p:commandButton id="addDetailBtn" value="add" action="#{cc.attrs.addAction}"
            update="detailsPanel" partialSubmit="true" process="@this detailsPanel"/>
    </cc:implementation>
</ui:component>

and the EntityUiController is

@Named
@ViewScoped
public class EntityUiController implements Serializable {

    private static final Logger LOG = Logger.getLogger( EntityUiController.class.getName() );

    @Inject
    private CategoriesBoundary categoriesBoundary;

    @Valid
    private Entity entity;

    @PostConstruct
    public void init() {
        this.entity = new Entity();
    }

    public Entity getEntity() {
        return entity;
    }

    public void saveAction() {
        LOG.log(Level.INFO, "saved entity: {0}", this.entity );
    }

    public void addAction() {
        this.entity.getDetails().add( new Detail() );
    }
    
    public List<SelectItem> getCategoryItems() {
        return categoriesBoundary.getCategories().stream()
            .map( cat -> new SelectItem( cat, cat.getDescription() ) )
            .collect( Collectors.toList() );
    }

    public Converter<Category> getCategoriesConverter() {
        return new Converter<Category>() {

            @Override
            public String getAsString(FacesContext context, UIComponent component, Category value) {
                return value==null ? null : Integer.toString( value.getId() );
            }

            @Override
            public Category getAsObject(FacesContext context, UIComponent component, String value) {
                if ( value==null || value.isEmpty() ) {
                    return null;
                }
                try {
                    return categoriesBoundary.findById( Integer.valueOf(value).intValue() );
                } catch (NumberFormatException e) {
                    throw new ConverterException(e);
                }
            }
        };
    }
}

When we now press the save-button in the above h:form, the name-inputText is validated correctly but the @NotEmpty- and @UniqueCategory-constraint on the details-property are ignored.

What am I missing?

We are on java-ee-7, payara 4.


Solution

  • After diving into this a bit we endet up with a solution using a backing component ValidateListComponent. It was inspired by UIValidateWholeBean and WholeBeanValidator. That component extends from UIInput and overrides the validation-Methods to operate on a clone of the above details-collection which gets populated with the already validated values of the children-UIInput. Seems to work for now.

    <ui:component ...>
        <cc:interface componentType="validatedListComponent">
            <cc:attribute name="addAction" required="true" method-signature="void action()"/>
        </cc:interface>
    
        <cc:implementation>
            ... see above ...
        </cc:implementation>
    </ui:component>
    

    with the backing component defined as

    @FacesComponent(value = "validatedListComponent")
    @SuppressWarnings("unchecked")
    public class ValidatedListComponent extends UIInput implements NamingContainer {
    
        @Override
        public String getFamily() {
            return "javax.faces.NamingContainer";
        }
    
        /**
         * Override {@link UIInput#processValidators(FacesContext)} to switch the order of 
         * validation. First validate this components children, then validate this itself.
         */
        @Override
        public void processValidators(FacesContext context) {
        
            // Skip processing if our rendered flag is false
            if (!isRendered()) {
                return;
            }
    
            pushComponentToEL(context, this);
    
            for (Iterator<UIComponent> i = getFacetsAndChildren(); i.hasNext(); ) {
                i.next().processValidators(context);
            }
            if (!isImmediate()) {
                Application application = context.getApplication();
                application.publishEvent(context, PreValidateEvent.class, this);
                executeValidate(context);
                application.publishEvent(context, PostValidateEvent.class, this);
            }
    
            popComponentFromEL(context);
        }
    
        /**
         * Override {@link UIInput#validate(FacesContext)} to validate a cloned collection
         * instead of the submitted value.
         *
         * Inspired by {@link UIValidateWholeBean} and {@link WholeBeanValidator}.
         */
        @Override
        public void validate(FacesContext context) {
    
            AreDetailsValidCallback callback = new AreDetailsValidCallback();
            visitTree( VisitContext.createVisitContext(context)
                     , callback
                     );
            if ( callback.isDetailsValid() ) {
                Collection<?> clonedValue = cloneCollectionAndSetDetailValues( context );
                validateValue(context, clonedValue);
            }
        }
    
        /**
         * private method copied from {@link UIInput#executeValidate(FacesContext)}.
         * @param context
         */
        private void executeValidate(FacesContext context) {
            try {
                validate(context);
            } catch (RuntimeException e) {
                context.renderResponse();
                throw e;
            }
    
            if (!isValid()) {
                context.validationFailed();
                context.renderResponse();
            }
        }
    
        private Collection<Object> cloneCollectionAndSetDetailValues(FacesContext context) {
            ValueExpression collectionVE = getValueExpression("value");
            Collection<?> baseCollection = (Collection<?>) collectionVE.getValue(context.getELContext());
            if ( baseCollection==null ) {
               return null;
            }
    
            // Visit all the components children to find their already validated values.
            FindDetailValuesCallback callback = new FindDetailValuesCallback(context);
            this.visitTree( VisitContext.createVisitContext(context)
                    , callback
                    );
    
            // Clone this components value and put in all cloned details with validated values set.
            try {
                Collection<Object> clonedCollection = baseCollection.getClass().newInstance();
    
                for( Entry<Object,Map<String,Object>> entry : callback.getDetailSubmittedData().entrySet() ) {
                    Object clonedDetail = cloneDetailAndSetValues( entry.getKey(), entry.getValue() );
                    clonedCollection.add( clonedDetail );
                }
    
                return clonedCollection;
            } catch ( Exception e ) {
               throw new ConverterException(e);
            }
        }
    
        private <T> T cloneDetailAndSetValues(T detail, Map<String, Object> propertyMap) throws Exception {
            T clonedDetail = clone(detail);
            // check the properties we have in the detail
            Map<String, PropertyDescriptor> availableProperties = new HashMap<>();
            for (PropertyDescriptor propertyDescriptor : getBeanInfo(detail.getClass()).getPropertyDescriptors()) {
                availableProperties.put(propertyDescriptor.getName(), propertyDescriptor);
            }
            // put their value (or local value) into our clone
            for (Map.Entry<String, Object> propertyToSet : propertyMap.entrySet()) {
                availableProperties.get(propertyToSet.getKey()).getWriteMethod().invoke(clonedDetail,
                        propertyToSet.getValue());
            }
    
            return clonedDetail;
        }
    
        private static <T> T clone(T object) throws Exception {
            // clone an object using serialization.
            ByteArrayOutputStream byteArrayOutputStream = new ByteArrayOutputStream();
            ObjectOutputStream out = new ObjectOutputStream(byteArrayOutputStream);
    
            out.writeObject(object);
            byte[] bytes = byteArrayOutputStream.toByteArray();
    
            ByteArrayInputStream byteArrayInputStream = new ByteArrayInputStream(bytes);
            ObjectInputStream in = new ObjectInputStream(byteArrayInputStream);
    
            return (T) in.readObject();
        }
    
        private class FindDetailValuesCallback implements VisitCallback {
    
            private final FacesContext context;
            private final Map<Object, Map<String, Object>> detailSubmittedData = new HashMap<>();
    
            public FindDetailValuesCallback(final FacesContext context) {
                this.context = context;
            }
    
            final Map<Object, Map<String, Object>> getDetailSubmittedData() {
                return detailSubmittedData;
            }
    
            @Override
            public VisitResult visit(VisitContext visitContext, UIComponent component) {
                if ( isVisitorTarget(component) ) {
                    ValueExpression ve = component.getValueExpression("value");
                    Object value = ((EditableValueHolder)component).getValue();
    
                    if (ve != null) {
    
                        ValueReference vr = ve.getValueReference(context.getELContext());
                        String prop = (String)vr.getProperty();
                        Object base = vr.getBase();
    
                        Map<String, Object> propertyMap
                            = Optional.ofNullable( detailSubmittedData.get(base) )
                               .orElseGet( HashMap::new );
                        propertyMap.put(prop, value );
    
                        detailSubmittedData.putIfAbsent( base, propertyMap);
                    }
                }
    
                return ACCEPT;
            }
    
        }
    
        private class AreDetailsValidCallback implements VisitCallback {
    
            private boolean detailsValid;
    
            public AreDetailsValidCallback() {
                this.detailsValid = true;
            }
    
            public boolean isDetailsValid() {
                return detailsValid;
            }
    
            @Override
            public VisitResult visit(VisitContext visitContext, UIComponent component) {
                if ( isVisitorTarget(component) ) {
                    if ( !((EditableValueHolder)component).isValid() ) {
                        this.detailsValid = false;
                    }
                }
                return ACCEPT;
            }
    
        }
    
        private boolean isVisitorTarget( UIComponent component ) {
            return component instanceof EditableValueHolder && component.isRendered()
                    && component!=ValidatedListComponent.this;
        }
    }
    

    UPDATE: sometimes it is a problem to obtain the ValueReference in FindDetailValuesCallback#visit. The answer given by Michele here solved that problem.