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 }