001/*
002 *  Copyright 2018 Anyware Services
003 *
004 *  Licensed under the Apache License, Version 2.0 (the "License");
005 *  you may not use this file except in compliance with the License.
006 *  You may obtain a copy of the License at
007 *
008 *      http://www.apache.org/licenses/LICENSE-2.0
009 *
010 *  Unless required by applicable law or agreed to in writing, software
011 *  distributed under the License is distributed on an "AS IS" BASIS,
012 *  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
013 *  See the License for the specific language governing permissions and
014 *  limitations under the License.
015 */
016package org.ametys.runtime.model;
017
018import java.util.ArrayList;
019import java.util.Collection;
020import java.util.Collections;
021import java.util.HashMap;
022import java.util.List;
023import java.util.Map;
024import java.util.Set;
025
026import org.apache.commons.lang3.StringUtils;
027import org.apache.commons.lang3.tuple.ImmutablePair;
028import org.apache.commons.lang3.tuple.Pair;
029import org.slf4j.Logger;
030import org.slf4j.LoggerFactory;
031
032import org.ametys.runtime.config.DisableCondition;
033import org.ametys.runtime.config.DisableCondition.OPERATOR;
034import org.ametys.runtime.config.DisableConditions;
035import org.ametys.runtime.i18n.I18nizableText;
036import org.ametys.runtime.model.exception.UndefinedItemPathException;
037import org.ametys.runtime.model.type.ElementType;
038import org.ametys.runtime.parameter.Errors;
039import org.ametys.runtime.parameter.Validator;
040
041/**
042 * Helper class for models
043 */
044public final class ModelHelper
045{
046    private static final Logger __LOGGER = LoggerFactory.getLogger(ModelHelper.class);
047    
048    private ModelHelper()
049    {
050        // Empty constructor
051    }
052    
053    /**
054     * Checks if there is a model item at the given relative path
055     * @param path path of the model item. This path is relative to the given containers. No matter if it is a definition or data path (with repeater entry positions)
056     * @param itemContainers model item containers where to search if there is a model item
057     * @return <code>true</code> if there is model item at this path, <code>false</code> otherwise
058     */
059    public static boolean hasModelItem(String path, Collection<? extends ModelItemContainer> itemContainers)
060    {
061        try
062        {
063            getModelItem(path, itemContainers);
064            return true;
065        }
066        catch (UndefinedItemPathException e)
067        {
068            return false;
069        }
070    }
071    
072    /**
073     * Retrieves the model item at the given relative path
074     * @param path path of the model item to retrieve. This path is relative to the given containers. No matter if it is a definition or data path (with repeater entry positions)
075     * @param itemContainers model item containers where to search the model item
076     * @return the model item
077     * @throws IllegalArgumentException if the given path is null or empty
078     * @throws UndefinedItemPathException if there is no item defined at the given path in given item containers
079     */
080    public static ModelItem getModelItem(String path, Collection<? extends ModelItemContainer> itemContainers) throws IllegalArgumentException, UndefinedItemPathException
081    {
082        if (StringUtils.isEmpty(path))
083        {
084            throw new IllegalArgumentException("Unable to retrieve the model item at the given path. This path is empty.");
085        }
086        
087        String definitionPath = getDefinitionPathFromDataPath(path);
088        
089        ModelItem item = null;
090        for (ModelItemContainer container : itemContainers)
091        {
092            if (container.hasModelItem(definitionPath))
093            {
094                item = container.getModelItem(definitionPath);
095                break;
096            }
097        }
098        
099        if (item != null)
100        {
101            return item;
102        }
103        else
104        {
105            String absolutePath = path;
106            if (itemContainers.size() == 1)
107            {
108                ModelItemContainer container = itemContainers.iterator().next();
109                if (container instanceof ModelItemGroup)
110                {
111                    absolutePath = ((ModelItemGroup) container).getPath() + ModelItem.ITEM_PATH_SEPARATOR + path;
112                }
113            }
114            
115            throw new UndefinedItemPathException("Unable to retrieve the model item at path '" + absolutePath + "'. This path is not defined by the model.");
116        }
117    }
118    
119    /**
120     * Retrieve the list of successive model items represented by the given paths, indexed by path.
121     * @param paths paths of the model items to retrieve. These paths are relative to the given containers. No matter if they are definition or data paths (with repeater entry positions)
122     * @param itemContainers model item containers where to search the model items
123     * @return the list of successive model items, indexed by path
124     * @throws IllegalArgumentException if one of the given paths is null or empty
125     * @throws UndefinedItemPathException if there is no item defined at one of the given paths in given item containers
126     */
127    public static Map<String, List<ModelItem>> getAllModelItemsInPaths(Set<String> paths, Collection<? extends ModelItemContainer> itemContainers) throws IllegalArgumentException, UndefinedItemPathException
128    {
129        Map<String, List<ModelItem>> result = new HashMap<>();
130        
131        for (String path : paths)
132        {
133            result.put(path, ModelHelper.getAllModelItemsInPath(path, itemContainers));
134        }
135        
136        return result;
137    }
138    
139    /**
140     * Retrieve the list of successive model items represented by the given path.
141     * @param path path of the model items to retrieve. This path is relative to the given containers. No matter if it is a definition or data path (with repeater entry positions)
142     * @param itemContainers model item containers where to search the model items
143     * @return the list of successive model items
144     * @throws IllegalArgumentException if the given path is null or empty
145     * @throws UndefinedItemPathException if there is no item defined at the given path in given item containers
146     */
147    public static List<ModelItem> getAllModelItemsInPath(String path, Collection<? extends ModelItemContainer> itemContainers) throws IllegalArgumentException, UndefinedItemPathException
148    {
149        if (StringUtils.isEmpty(path))
150        {
151            throw new IllegalArgumentException("Unable to retrieve the model item at the given path. This path is empty.");
152        }
153        
154        String definitionPath = getDefinitionPathFromDataPath(path);
155        
156        for (ModelItemContainer itemContainer : itemContainers)
157        {
158            // If the current container has the model item at given path, no need to check in the other containers
159            // Given containers represent only one whole model, so a path can't refer to different definitions
160            List<ModelItem> modelItemPath = _getAllModelItemsInPath(definitionPath, itemContainer);
161            if (!modelItemPath.isEmpty())
162            {
163                return modelItemPath;
164            }
165        }
166        
167        // No path has been found in the different containers
168        throw new UndefinedItemPathException("Unable to retrieve the model items at path '" + definitionPath + "'. This path is not defined by the model.");
169    }
170    
171    private static List<ModelItem> _getAllModelItemsInPath(String definitionPath, ModelItemContainer itemContainer)
172    {
173        List<ModelItem> definitions = new ArrayList<>();
174
175        String[] pathSegments = StringUtils.split(definitionPath, ModelItem.ITEM_PATH_SEPARATOR);
176        
177        ModelItemContainer currentModelItemContainer = itemContainer;
178        for (int i = 0; i < pathSegments.length; i++)
179        {
180            ModelItem modelItem = currentModelItemContainer.getChild(pathSegments[i]);
181            if (modelItem != null)
182            {
183                definitions.add(modelItem);
184            }
185            else
186            {
187                return Collections.emptyList();
188            }
189            
190            if (modelItem instanceof ModelItemContainer)
191            {
192                currentModelItemContainer = (ModelItemContainer) modelItem;
193            }
194        }
195        
196        return definitions;
197    }
198    
199
200    /**
201     * Determines if a container of model items contains a model item of the given type
202     * @param container the model item container
203     * @param type the type identifier to find.
204     * @return true if a model item of the given type is found
205     */
206    public static boolean hasModelItemOfType(ModelItemContainer container, String type)
207    {
208        for (ModelItem childItem : container.getModelItems())
209        {
210            if (type.equals(childItem.getType().getId()))
211            {
212                return true;
213            }
214            else if (childItem instanceof ModelItemGroup)
215            {
216                // recurse on repeater and composites
217                if (hasModelItemOfType((ModelItemGroup) childItem, type))
218                {
219                    return true;
220                }
221            }
222        }
223        
224        return false;
225    }
226    
227    /**
228     * Find all model items of the given type
229     * @param container the model item container
230     * @param type the type identifier to find.
231     * @return the list of {@link ModelItem}s of this type
232     */
233    public static List<ModelItem> findModelItemsByType(ModelItemContainer container, String type)
234    {
235        List<ModelItem> items = new ArrayList<>();
236        
237        for (ModelItem childItem : container.getModelItems())
238        {
239            if (type.equals(childItem.getType().getId()))
240            {
241                items.add(childItem);
242            }
243            else if (childItem instanceof ModelItemGroup)
244            {
245                // recurse on repeater and composites
246                items.addAll(findModelItemsByType((ModelItemGroup) childItem, type));
247            }
248        }
249        
250        return items;
251    }
252    
253    /**
254     * Retrieves the given dataPath as a definition path (without the repeaterEntry positions)
255     * @param dataPath the data path
256     * @return the definition path
257     */
258    public static String getDefinitionPathFromDataPath(String dataPath)
259    {
260        return dataPath.replaceAll("\\[[0-9]+\\]", "");
261    }
262    
263    /**
264     * Checks if this item is in a group with a switch on
265     * @param modelItem the item to check
266     * @param values all items' values to get switchers' values
267     * @return false if this item is part of a group with a switch to off, true otherwise 
268     */
269    public static boolean isGroupSwitchOn(ModelItem modelItem, Map<String, Object> values)
270    {
271        Pair<Boolean, ElementDefinition> isGroupActive = _isModelItemGroupActive(modelItem.getParent(), values);
272        if (isGroupActive.getKey())
273        {
274            return true;
275        }
276        return modelItem.equals(isGroupActive.getValue());
277    }
278    
279    private static Pair<Boolean, ElementDefinition> _isModelItemGroupActive(ModelItemGroup group, Map<String, Object> values)
280    {
281        if (group == null)
282        {
283            return new ImmutablePair<>(true, null);
284        }
285        
286        ElementDefinition<Boolean> groupSwitch = group.getSwitcher();
287        if (groupSwitch == null)
288        {
289            return _isModelItemGroupActive(group.getParent(), values);
290        }
291        
292        Object value = values.get(groupSwitch.getName());
293        
294        if (value == null)
295        {
296            value = groupSwitch.getDefaultValue();
297        }
298        
299        if (!(value instanceof Boolean))
300        {
301            throw new IllegalStateException("The switcher value of group " + group.getName() + " is null or not a boolean");
302        }
303        
304        return (Boolean) value ? _isModelItemGroupActive(group.getParent(), values) : new ImmutablePair<>(false, groupSwitch);
305    }
306    
307    /**
308     * Recursively evaluate the {@link DisableConditions} against the given values
309     * @param disableConditions the disable conditions to evaluate
310     * @param definitionAndValues the values to evaluate
311     * @param logger the logger for disable conditions evaluation logs
312     * @return true if the disable conditions are true, false otherwise
313     */
314    public static boolean evaluateDisableConditions(DisableConditions disableConditions, Map<String, DefinitionAndValue> definitionAndValues, Logger logger)
315    {
316        if (!_hasDisableConditions(disableConditions))
317        {
318            return false;
319        }
320        
321        boolean disabled;
322        boolean andOperator = disableConditions.getAssociationType() == DisableConditions.ASSOCIATION_TYPE.AND;
323        
324        // initial value depends on OR or AND associations
325        disabled = andOperator;
326        
327        for (DisableConditions subConditions : disableConditions.getSubConditions())
328        {
329            boolean result = evaluateDisableConditions(subConditions, definitionAndValues, logger);
330            disabled = andOperator ?  disabled && result : disabled || result;
331        }
332        
333        for (DisableCondition condition : disableConditions.getConditions())
334        {
335            boolean result = _evaluateCondition(condition, definitionAndValues, logger);
336            disabled = andOperator ?  disabled && result : disabled || result;
337        }
338                
339        return disabled;
340    }
341
342    private static boolean _hasDisableConditions(DisableConditions disableConditions)
343    {
344        return disableConditions != null && !disableConditions.getConditions().isEmpty() && disableConditions.getSubConditions().isEmpty();
345    }
346    
347    private static boolean _evaluateCondition(DisableCondition condition, Map<String, DefinitionAndValue> definitionAndValues, Logger logger)
348    {
349        String id = condition.getId();
350        DisableCondition.OPERATOR operator = condition.getOperator();
351        String conditionValue = condition.getValue();
352        
353        DefinitionAndValue definitionAndValue = definitionAndValues.get(id);
354        if (definitionAndValue == null || !(definitionAndValue.getDefinition() instanceof ElementDefinition))
355        {
356            logger.debug("Cannot evaluate the disable condition on the undefined element {}.\nReturning false.", id);
357            return false;
358        }
359        
360        ElementType type = ((ElementDefinition) definitionAndValue.getDefinition()).getType();
361        Object compareValue = type.castValue(conditionValue);
362        if (compareValue == null)
363        {
364            throw new IllegalStateException("Cannot convert the condition value '" + conditionValue + "' to a '" + type.getId() + "' for model item '" + id + "'");
365        }
366        
367        try
368        {
369            Object value = definitionAndValue.getValue();
370            if (value instanceof Collection)
371            {
372                if (operator == OPERATOR.EQ)
373                {
374                    for (Object v: (Collection) value)
375                    {
376                        if (_evaluateConditionValue(v, operator, compareValue))
377                        {
378                            // One entry matches
379                            return true;
380                        }
381                    }
382                
383                    return false;
384                }
385                else
386                {
387                    for (Object v: (Collection) value)
388                    {
389                        if (!_evaluateConditionValue(v, operator, compareValue))
390                        {
391                            // One entry does not match
392                            return false;
393                        }
394                    }
395                
396                    return true;
397                }
398            }
399            else
400            {
401                return _evaluateConditionValue(value, operator, compareValue);
402            }
403        }
404        catch (Exception e)
405        {
406            throw new IllegalStateException("An error occurred while comparing values in type'" + type + "' for model item '" + id + "'.", e);
407        }
408    }
409    private static boolean _evaluateConditionValue(Object value, DisableCondition.OPERATOR operator, Object compareValue)
410    {
411        if ((value == null || value instanceof Comparable) && (compareValue == null || compareValue instanceof Comparable))
412        {
413            @SuppressWarnings("unchecked")
414            Comparable<Object> comparableParameterValue = (Comparable<Object>) _emptyStringToNull(value);
415            @SuppressWarnings("unchecked")
416            Comparable<Object> comparableCompareValue = (Comparable<Object>) _emptyStringToNull(compareValue);
417            
418            int comparison;
419            if (comparableParameterValue != null && comparableCompareValue != null)
420            {
421                comparison = comparableParameterValue.compareTo(comparableCompareValue);
422            }
423            else if (comparableCompareValue != null)
424            {
425                comparison = -1; // null comparableParameterValue is considered less than non-null comparableCompareValue
426            }
427            else if (comparableParameterValue != null)
428            {
429                comparison = 1; // non-null comparableParameterValue is considered greater than null comparableCompareValue
430            }
431            else
432            {
433                comparison = 0; // both are null
434            }
435            
436            switch (operator)
437            {
438                case NEQ:
439                    return comparison != 0;
440                case GEQ:
441                    return comparison >= 0;
442                case GT:
443                    return comparison > 0;
444                case LT:
445                    return comparison < 0;
446                case LEQ:
447                    return comparison <= 0;
448                case EQ:
449                default:
450                    return comparison == 0;
451            }
452        }
453        else
454        {
455            throw new IllegalStateException("Values '" + value + "' and '" + compareValue + "' are not comparable");
456        }
457    }
458    
459    private static Object _emptyStringToNull(Object compareValue)
460    {
461        if ("".equals(compareValue))
462        {
463            return null;
464        }
465        else
466        {
467            return  compareValue;
468        }
469    }
470
471    /**
472     * Validates the given value
473     * @param definition The definition to use to validate the value
474     * @param value the value to validate
475     * @return the structure with errors information if the validation failed.
476     */
477    public static List<I18nizableText> validateValue(ElementDefinition definition, Object value)
478    {
479        return validateValue(definition, value, true);
480    }
481    
482    /**
483     * Validates the given value
484     * @param definition The definition to use to validate the value
485     * @param value the value to validate
486     * @param checkEnumerated <code>true</code> true to make sure that the item with an enumerator has its value in the enumerated values
487     * @return the structure with errors information if the validation failed.
488     * TODO NEWATTRIBUTEAPI RUNTIME-2897: remove this method to always check enumerator when validating a value 
489     */
490    public static List<I18nizableText> validateValue(ElementDefinition definition, Object value, boolean checkEnumerated)
491    {
492        List<I18nizableText> errorsList = new ArrayList<>();
493        Errors errors = new Errors();
494
495        ElementType type = definition.getType();
496        if (value != null && !type.getManagedClass().isInstance(value) && !type.getManagedClassArray().isInstance(value))
497        {
498            errorsList.add(new I18nizableText("plugin.core", "PLUGINS_CORE_ELEMENT_DEFINITION_VALUE_NOT_ALLOWED"));
499        }
500        
501        Validator validator = definition.getValidator();
502        if (validator != null)
503        {
504            validator.validate(value, errors);
505            if (errors.hasErrors())
506            {
507                errorsList.addAll(errors.getErrors());
508            }
509        }
510        
511        if (checkEnumerated && _checkValueEnumeration(value))
512        {
513            // Make sure that the item with an enumerator has its value in the enumerated values
514            Enumerator<Object> enumerator = definition.getEnumerator();
515            if (enumerator != null)
516            {
517                I18nizableText entry = null;
518                try
519                {
520                    entry = enumerator.getEntry(value);
521                }
522                catch (Exception e)
523                {
524                    __LOGGER.warn("An error occured while checking enumerated value '{}' for '{}'", value, enumerator, e);
525                    errorsList.add(new I18nizableText("plugin.core", "PLUGINS_CORE_ELEMENT_DEFINITION_VALUE_LED_TO_EXCEPTION"));
526                }
527                
528                if (entry == null)
529                {
530                    errorsList.add(new I18nizableText("plugin.core", "PLUGINS_CORE_ELEMENT_DEFINITION_VALUE_NOT_ALLOWED"));
531                }
532            }
533        }
534            
535        return errorsList;
536    }
537    
538    private static boolean _checkValueEnumeration(Object value)
539    {
540        if (value == null)
541        {
542            return false;
543        }
544        else
545        {
546            return value instanceof String ? StringUtils.isNotEmpty((String) value) : true;
547        }
548    }
549}