001    /*
002      GRANITE DATA SERVICES
003      Copyright (C) 2012 GRANITE DATA SERVICES S.A.S.
004    
005      This file is part of Granite Data Services.
006    
007      Granite Data Services is free software; you can redistribute it and/or modify
008      it under the terms of the GNU Library General Public License as published by
009      the Free Software Foundation; either version 2 of the License, or (at your
010      option) any later version.
011    
012      Granite Data Services is distributed in the hope that it will be useful, but
013      WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
014      FITNESS FOR A PARTICULAR PURPOSE. See the GNU Library General Public License
015      for more details.
016    
017      You should have received a copy of the GNU Library General Public License
018      along with this library; if not, see <http://www.gnu.org/licenses/>.
019    */
020    
021    package org.granite.client.validation.javafx;
022    
023    import java.lang.ref.WeakReference;
024    import java.lang.reflect.Array;
025    import java.lang.reflect.Field;
026    import java.util.ArrayList;
027    import java.util.Collections;
028    import java.util.HashMap;
029    import java.util.HashSet;
030    import java.util.IdentityHashMap;
031    import java.util.Iterator;
032    import java.util.List;
033    import java.util.Map;
034    import java.util.Map.Entry;
035    import java.util.Set;
036    
037    import javafx.beans.property.BooleanProperty;
038    import javafx.beans.property.Property;
039    import javafx.beans.property.ReadOnlyBooleanProperty;
040    import javafx.beans.property.SimpleBooleanProperty;
041    import javafx.beans.value.ChangeListener;
042    import javafx.beans.value.ObservableValue;
043    import javafx.collections.FXCollections;
044    import javafx.collections.ListChangeListener;
045    import javafx.collections.ObservableList;
046    import javafx.event.Event;
047    import javafx.event.EventHandler;
048    import javafx.event.EventTarget;
049    import javafx.scene.Node;
050    import javafx.scene.Parent;
051    import javafx.scene.control.Skinnable;
052    import javafx.scene.control.TextInputControl;
053    
054    import javax.validation.ConstraintViolation;
055    import javax.validation.TraversableResolver;
056    import javax.validation.Validation;
057    import javax.validation.ValidatorFactory;
058    import javax.validation.groups.Default;
059    
060    import org.granite.client.util.javafx.DataNotifier;
061    import org.granite.client.validation.ValidationResult;
062    import org.granite.logging.Logger;
063    
064    /**
065     * @author William DRAI
066     */
067    public class FormValidator {
068            
069            private static final Logger log = Logger.getLogger(FormValidator.class);
070    
071            public static final String UNHANDLED_VIOLATIONS = "unhandledViolations";
072            
073            protected Parent form;
074            
075            protected List<Node> inputs = new ArrayList<Node>();
076            protected Map<Node, Property<?>> inputProperties = new IdentityHashMap<Node, Property<?>>();
077            protected Map<Node, Property<?>> entityProperties = new IdentityHashMap<Node, Property<?>>();
078            protected Set<Node> focusedOutOnce = new HashSet<Node>();
079    
080            protected List<ConstraintViolation<?>> violations = new ArrayList<ConstraintViolation<?>>();
081            protected ObservableList<ConstraintViolation<?>> unhandledViolations = FXCollections.observableArrayList();
082            
083            
084            /**
085             * The <code>ValidatorFactory</code> to be used in the validation
086             * process (initialized with the the default instance).
087             */
088            private final ValidatorFactory validatorFactory;
089            
090            public FormValidator() {
091                    this.validatorFactory = Validation.buildDefaultValidatorFactory();
092            }
093            
094            public FormValidator(TraversableResolver traversableResolver) {
095                    this.validatorFactory = Validation.byDefaultProvider().configure().traversableResolver(traversableResolver).buildValidatorFactory();
096            }
097            
098            public FormValidator(ValidatorFactory validatorFactory) {
099                    this.validatorFactory = validatorFactory;
100            }
101            
102            /**
103             * Should validation be done on the fly? Otherwise, validation will be
104             * only done when an input loses focus. Default is true.
105             */
106            public BooleanProperty validateOnChangeProperty = new SimpleBooleanProperty(this, "validateOnChange", true);
107            
108            public boolean isValidateOnChange() {
109                    return validateOnChangeProperty.get();
110            }
111            public void setValidateOnChange(boolean validateOnChange) {
112                    this.validateOnChangeProperty.set(validateOnChange);
113            }
114            
115            
116            public boolean validate(EventTarget entity) {
117                    Set<ConstraintViolation<Object>> allViolations = validatorFactory.getValidator().validate((Object)entity, groups);
118                    
119                    Map<Object, Set<ConstraintViolation<?>>> violationsMap = new HashMap<Object, Set<ConstraintViolation<?>>>();
120                    for (ConstraintViolation<Object> violation : allViolations) {
121                            Object rootBean = violation.getRootBean();
122                            Object leafBean = violation.getLeafBean();
123                            Object bean = leafBean != null && leafBean instanceof DataNotifier ? leafBean : rootBean;
124                            
125                            Set<ConstraintViolation<?>> violations = violationsMap.get(bean);
126                            if (violations == null) {
127                                    violations = new HashSet<ConstraintViolation<?>>();
128                                    violationsMap.put(bean, violations);
129                            }                       
130                            violations.add(violation);
131                    }
132                    
133                    for (Object bean : violationsMap.keySet()) {
134                            if (bean instanceof DataNotifier) {
135                                    ConstraintViolationEvent event = new ConstraintViolationEvent(ConstraintViolationEvent.CONSTRAINT_VIOLATION, violationsMap.get(bean));
136                                    Event.fireEvent((DataNotifier)bean, event);
137                            }
138                    }
139                    
140                    focusedOutOnce.addAll(inputs);
141                    
142                    return allViolations.isEmpty();
143            }
144            
145            
146            /**
147             * The validation groups to be used, as an array of <code>Class</code>
148             * names. Default is null, meaning that the <code>Default</code> group
149             * will be used.
150             */
151            public Class<?>[] groups = new Class<?>[] { Default.class };
152            
153            /**
154             * The form component that contains inputs bound to the entity properties
155             * (may be a <code>Form</code> or any other <code>Container</code>
156             * subclass).
157             */
158            public Parent getForm() {
159                    return form;
160            }
161            public void setForm(Parent form) {
162                    if (form == this.form)
163                            return;
164                    
165                    if (this.form != null)
166                            setupForm(null);
167                    
168                    this.form = form;
169                    
170                    if (this.form != null)
171                            setupForm(this.form);
172            }
173    
174            /**
175             * Returns the result of the last global validation as an array of
176             * <code>ConstraintViolation</code>s.
177             * 
178             * @return the result of the last global validation as an array of
179             *              <code>ConstraintViolation</code>s.
180             */
181            public List<ConstraintViolation<?>> getViolations() {
182                    return violations;
183            }
184    
185            /**
186             * Returns the <i>unhandled</i> violations of the last global validation
187             * as an array of <code>ConstraintViolation</code>s. Unhandled violations
188             * are violations that couldn't be associated to any input during the
189             * last global validation (thus, they couldn't be displayed anywhere).
190             * 
191             * @return the <i>unhandled</i> violations of the last global validation
192             *              as an array of <code>ConstraintViolation</code>s.
193             */
194            public List<ConstraintViolation<?>> getUnhandledViolations() {
195                    return unhandledViolations;
196            }
197            
198            
199            protected void setupForm(Parent form) {
200                    // Untrack child nodes
201                    untrackNode(this.form);
202                    
203                    if (!inputs.isEmpty()) {
204                            inputs.clear();
205                            log.warn("Inputs were not cleared correctly");
206                    }
207                    if (!inputProperties.isEmpty()) {
208                            inputProperties.clear();
209                            log.warn("Input properties were not cleared correctly");
210                    }
211                    if (!entityProperties.isEmpty()) {
212                            entityProperties.clear();
213                            log.warn("Entity properties were not cleared correctly");
214                    }
215                    if (!trackedParents.isEmpty()) {
216                            trackedParents.clear();
217                            log.warn("Tracked parents were not cleared correctly");
218                    }
219                            
220                    focusedOutOnce.clear();
221    
222                    if (form != null)
223                            trackNode(form);
224            }
225            
226    
227            private ListChangeListener<Node> childChangeListener = new ChildChangeListener();
228        
229        public class ChildChangeListener implements ListChangeListener<Node> {        
230                    @Override
231                    public void onChanged(ListChangeListener.Change<? extends Node> change) {
232                            while (change.next()) {
233                                    if (change.wasReplaced() && change.getRemovedSize() == 1 && change.getAddedSize() == 1 && change.getAddedSubList().get(0) == change.getRemoved().get(0))
234                                            continue;
235                                    
236                                    if (change.wasRemoved()) {
237                                            for (Node node : change.getRemoved())
238                                                    untrackNode(node);
239                                    }
240                                    if (change.wasAdded()) {
241                                            for (Node node : change.getAddedSubList())
242                                                    trackNode(node);
243                                    }
244                                    if (change.wasPermutated()) {
245                                            log.debug("Permutation ??");
246                                    }
247                            }
248                    }
249        }
250        
251        
252        private IdentityHashMap<Parent, Boolean> trackedParents = new IdentityHashMap<Parent, Boolean>();
253        
254    
255            protected void trackNode(Node node) {
256                    if (form == null)
257                            return;
258                    
259                    setupNode(node);
260                    
261                    if (node instanceof Skinnable && ((Skinnable)node).getSkin() != null)
262                            trackNode(((Skinnable)node).getSkin().getNode());
263                    
264                    if (node instanceof Parent && !trackedParents.containsKey(node)) {
265                            for (Node child : ((Parent)node).getChildrenUnmodifiable())
266                                    trackNode(child);
267                            
268                            ((Parent)node).getChildrenUnmodifiable().addListener(childChangeListener);
269                            trackedParents.put((Parent)node, true);
270                            
271                            log.debug("Setup children tracking for parent %s", node);
272                    }
273            }
274            
275            protected void untrackNode(Node node) {
276                    if (form == null)
277                            return;
278                    
279                    if (node instanceof Parent) {
280                            ((Parent)node).getChildrenUnmodifiable().removeListener(childChangeListener);
281                            trackedParents.remove(node);
282                            
283                            log.debug("Unset children tracking for parent %s", node.toString());
284                            
285                            for (Node child : ((Parent)node).getChildrenUnmodifiable())
286                                    untrackNode(child);
287                    }
288                    
289                    if (node instanceof Skinnable && ((Skinnable)node).getSkin() != null)
290                            untrackNode(((Skinnable)node).getSkin().getNode());
291                    
292                    unsetupNode(node);
293            }
294            
295            private void setupNode(Node node) {
296                    // If node is already tracked, clear everything in case user did not unbind old data
297                    if (inputProperties.containsKey(node)) {
298                            Property<?> entityProperty = entityProperties.remove(node);
299                            Property<?> inputProperty = inputProperties.remove(node);
300                            
301                            if (entityProperty != null && entityProperty.getBean() instanceof DataNotifier)
302                                    ((DataNotifier)entityProperty.getBean()).removeEventHandler(ConstraintViolationEvent.CONSTRAINT_VIOLATION, constraintViolationHandler);
303                            
304                            inputProperty.removeListener(valueChangeListener);
305                            node.focusedProperty().removeListener(inputFocusChangeListener);
306                            
307                            inputs.remove(node);
308                            log.debug("Cleanup old tracking for fantom node %s input %s entity %s", node, inputProperty.getName(), entityProperty);
309                    }
310                    
311                    Property<?> inputProperty = null;
312                    
313                    if (node instanceof TextInputControl)
314                            inputProperty = ((TextInputControl)node).textProperty();
315                    
316                    if (inputProperty != null) {
317                            Property<?> entityProperty = lookupBindingTarget(inputProperty);
318                            if (entityProperty != null) {
319                                    inputProperties.put(node, inputProperty);
320                                    entityProperties.put(node, entityProperty);
321                                    
322                                    if (entityProperty.getBean() instanceof DataNotifier)
323                                            ((DataNotifier)entityProperty.getBean()).addEventHandler(ConstraintViolationEvent.CONSTRAINT_VIOLATION, constraintViolationHandler);
324                                    
325                                    inputProperty.addListener(valueChangeListener);
326                                    node.focusedProperty().addListener(inputFocusChangeListener);
327                                    
328                                    inputs.add(node);
329                                    
330                                    log.debug("Setup tracking for node %s input %s entity %s", node, inputProperty.getName(), entityProperty);
331                            }
332                    }
333            }
334            
335            private void unsetupNode(Node node) {
336                    int idx = inputs.indexOf(node);
337                    if (idx >= 0) {
338                            Property<?> entityProperty = entityProperties.remove(node);
339                            if (entityProperty.getBean() instanceof DataNotifier)
340                                    ((DataNotifier)entityProperty.getBean()).removeEventHandler(ConstraintViolationEvent.CONSTRAINT_VIOLATION, constraintViolationHandler);
341                            
342                            node.fireEvent(new ValidationResultEvent(this, node, ValidationResultEvent.VALID, null));
343                            
344                            if (node instanceof TextInputControl)
345                                    ((TextInputControl)node).textProperty().removeListener(valueChangeListener);
346                            node.focusedProperty().removeListener(inputFocusChangeListener);
347                            
348                            Property<?> inputProperty = inputProperties.remove(node);                 
349                            inputs.remove(idx);
350                            
351                            log.debug("Unsetup tracking for node %s input %s entity %s", node, inputProperty.getName(), entityProperty);
352                    }
353            }
354            
355            /*
356             *      Ugly hack to determine target of bidirectional binding
357             */
358            private Property<?> lookupBindingTarget(Property<?> inputProperty) {
359                    try {
360                            Field fh = inputProperty.getClass().getDeclaredField("helper");
361                            fh.setAccessible(true);
362                            Object helper = fh.get(inputProperty);
363                            Field fcl = helper.getClass().getDeclaredField("changeListeners");
364                            fcl.setAccessible(true);
365                            Object changeListeners = fcl.get(helper);
366                            if (changeListeners != null && Array.getLength(changeListeners) > 0) {
367                                    ChangeListener<?> cl = (ChangeListener<?>)Array.get(changeListeners, 0);
368                                    try {
369                                            Field fpr = cl.getClass().getDeclaredField("propertyRef2");
370                                            fpr.setAccessible(true);
371                                            WeakReference<?> ref= (WeakReference<?>)fpr.get(cl);
372                                            Property<?> p = (Property<?>)ref.get();
373                                            return p;
374                                    }
375                                    catch (NoSuchFieldException e) {
376                                            log.debug("Field propertyRef2 not found on " + cl + ", probably not a standard binding", e);
377                                            return null;
378                                    }
379                            }
380                            log.debug("Could not find target binding for property %s", inputProperty);
381                            return null;
382                    }
383                    catch (Exception e) {
384                            log.warn(e, "Could not find target binding for property %s", inputProperty);
385                            return null;
386                    }
387            }
388            
389            
390            private ChangeListener<Boolean> inputFocusChangeListener = new InputFocusChangeListener();
391            private ChangeListener<Object> valueChangeListener = new ValueChangeListener();
392            
393            /**
394             * @private
395             */
396            private class InputFocusChangeListener implements ChangeListener<Boolean> {
397                    @Override
398                    public void changed(ObservableValue<? extends Boolean> change, Boolean oldValue, Boolean newValue) {
399                            if (Boolean.TRUE.equals(oldValue) && Boolean.FALSE.equals(newValue))
400                                    validateValue((Node)((ReadOnlyBooleanProperty)change).getBean(), true);
401                    }               
402            }
403    
404            private class ValueChangeListener implements ChangeListener<Object> {
405                    @SuppressWarnings("unchecked")
406                    @Override
407                    public void changed(ObservableValue<?> change, Object oldValue, Object newValue) {
408                            if (validateOnChangeProperty.get())                             
409                                    validateValue((Node)((Property<Object>)change).getBean(), false);
410                    }               
411            }
412            
413            
414            private ConstraintViolationHandler constraintViolationHandler = new ConstraintViolationHandler();
415            
416            private class ConstraintViolationHandler implements EventHandler<ConstraintViolationEvent> {
417                    @Override
418                    public void handle(ConstraintViolationEvent event) {
419                            for (ConstraintViolation<?> violation : event.getViolations()) {
420                                    Object leafBean = violation.getLeafBean();
421                                    String property = null;
422                                    Iterator<javax.validation.Path.Node> in = violation.getPropertyPath().iterator();
423                                    while (in.hasNext()) {
424                                            javax.validation.Path.Node n = in.next();
425                                            property = n.getName();
426                                    }
427                                    String[] path = property.split("\\.");
428                                    property = path[path.length-1];
429                                    
430                                    Node input = null;
431                                    for (Entry<Node, Property<?>> me : entityProperties.entrySet()) {
432                                            if (leafBean != null && leafBean.equals(me.getValue().getBean()) && me.getValue().getName().equals(property)) {
433                                                    input = me.getKey();
434                                                    break;
435                                            }
436                                    }
437                                    
438                                    if (input != null) {
439                                            List<ValidationResult> results = Collections.singletonList(new ValidationResult(true, entityProperties.get(input), "constraintViolation", violation.getMessage()));                                       
440                                            input.fireEvent(new ValidationResultEvent(this, input, ValidationResultEvent.INVALID, results));
441                                    }
442                            }
443                    }
444            }
445            
446            
447            /**
448             * @private
449             */
450            protected boolean validateValue(Node input, boolean focusOut) {
451                    Property<?> entityProperty = entityProperties.get(input);
452                    Property<?> inputProperty = inputProperties.get(input);
453                    if (entityProperty == null || inputProperty == null) {
454                            log.warn("validateValue called for untracked input " + input);
455                            return true;
456                    }
457                    
458                    if (focusOut)
459                            focusedOutOnce.add(input);
460                    
461                    boolean nulled = false;
462                    Object value = inputProperty.getValue();
463                    if ("".equals(value)) {
464                            value = null;
465                            nulled = true;
466                    }
467                    
468                    @SuppressWarnings("unchecked")
469                    Class<Object> entityClass = (Class<Object>)entityProperty.getBean().getClass();
470                    Set<ConstraintViolation<Object>> violations = validatorFactory.getValidator().validateValue(entityClass, entityProperty.getName(), value, groups);
471                    if (violations == null)
472                            violations = Collections.emptySet();
473                    if (violations.isEmpty() && !nulled)
474                            focusedOutOnce.add(input);
475                    else if (!focusedOutOnce.contains(input))
476                            return true;
477                    
478                    handleViolations(input, violations);
479                    
480                    return violations.isEmpty();
481            }
482    
483            /**
484             * @inheritDoc
485             */
486            protected void handleViolations(Node input, Set<ConstraintViolation<Object>> violations) {
487                    List<ValidationResultEvent> resultEvents = new ArrayList<ValidationResultEvent>();
488                    
489                    if (input != null) {
490                            if (!violations.isEmpty()) {
491                                    List<ValidationResult> results = new ArrayList<ValidationResult>();
492                                    for (ConstraintViolation<?> violation : violations)
493                                            results.add(new ValidationResult(true, entityProperties.get(input), "constraintViolation", violation.getMessage()));
494                                    
495                                    resultEvents.add(new ValidationResultEvent(this, input, ValidationResultEvent.INVALID, results));
496                            }
497                            else
498                                    resultEvents.add(new ValidationResultEvent(this, input, ValidationResultEvent.VALID, null));
499                    }
500                    else {
501                            Set<ConstraintViolation<?>> unhandledViolations = new HashSet<ConstraintViolation<?>>(violations);
502                            
503                            for (Node inp : inputs) {
504                                    List<ValidationResult> results = new ArrayList<ValidationResult>();
505                                    
506                                    Property<?> property = entityProperties.get(inp);
507                                    Iterator<ConstraintViolation<?>> iv = unhandledViolations.iterator();
508                                    while (iv.hasNext()) {
509                                            ConstraintViolation<?> violation = iv.next();
510                                            Iterator<javax.validation.Path.Node> in = violation.getPropertyPath().iterator();
511                                            javax.validation.Path.Node n = null;
512                                            while (in.hasNext())
513                                                    n = in.next();
514                                            
515                                            if (violation.getLeafBean().equals(property.getBean()) && n.getName().equals(property.getName())) {
516                                                    ValidationResult result = new ValidationResult(true, property, "constraintViolation", violation.getMessage());
517                                                    results.add(result);
518                                                    iv.remove();
519                                            }
520                                    }
521                                    
522                                    if (results.isEmpty()) {
523                                            // No violation for this input : add a valid result
524                                            resultEvents.add(new ValidationResultEvent(this, inp, ValidationResultEvent.VALID, null));
525                                    }
526                                    else {
527                                            resultEvents.add(new ValidationResultEvent(this, inp, ValidationResultEvent.INVALID, results));
528                                    }
529                            }
530                            
531                            this.unhandledViolations.clear();
532                            if (!unhandledViolations.isEmpty()) {
533                                    this.unhandledViolations.addAll(unhandledViolations);
534                                    
535                                    List<ValidationResult> unhandledResults = new ArrayList<ValidationResult>();
536                                    for (ConstraintViolation<?> violation : unhandledViolations)
537                                            unhandledResults.add(new ValidationResult(true, null, "constraintViolation", violation.getMessage()));
538                                    resultEvents.add(new ValidationResultEvent(this, form, ValidationResultEvent.UNHANDLED, unhandledResults));
539                            }
540                    }
541                    
542                    for (ValidationResultEvent resultEvent : resultEvents) {
543                            ((Node)resultEvent.getTarget()).fireEvent(resultEvent);
544                    }
545            }
546    }