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.LinkedHashMap;
023import java.util.List;
024import java.util.Map;
025import java.util.Set;
026
027import org.apache.avalon.framework.configuration.Configuration;
028import org.apache.avalon.framework.configuration.ConfigurationException;
029import org.apache.commons.lang3.StringUtils;
030import org.apache.commons.lang3.tuple.ImmutablePair;
031import org.apache.commons.lang3.tuple.Pair;
032import org.slf4j.Logger;
033import org.slf4j.LoggerFactory;
034
035import org.ametys.runtime.i18n.I18nizableText;
036import org.ametys.runtime.model.disableconditions.DisableCondition;
037import org.ametys.runtime.model.disableconditions.DisableConditions;
038import org.ametys.runtime.model.exception.UndefinedItemPathException;
039import org.ametys.runtime.model.type.ElementType;
040import org.ametys.runtime.parameter.ValidationResult;
041import org.ametys.runtime.parameter.Validator;
042
043/**
044 * Helper class for models
045 */
046public final class ModelHelper
047{
048    private static final Logger __LOGGER = LoggerFactory.getLogger(ModelHelper.class);
049    
050    private ModelHelper()
051    {
052        // Empty constructor
053    }
054    
055    /**
056     * Checks if there is a model item at the given relative path
057     * @param path path of the model item. This path is relative to the given accessors. No matter if it is a definition or data path (with repeater entry positions)
058     * @param itemAccessors model item accessors where to search if there is a model item
059     * @return <code>true</code> if there is model item at this path, <code>false</code> otherwise
060     * @throws IllegalArgumentException if the given path is null or empty
061     */
062    public static boolean hasModelItem(String path, Collection<? extends ModelItemAccessor> itemAccessors) throws IllegalArgumentException
063    {
064        try
065        {
066            getModelItem(path, itemAccessors);
067            return true;
068        }
069        catch (UndefinedItemPathException e)
070        {
071            return false;
072        }
073    }
074    
075    /**
076     * Retrieves the model item at the given relative path
077     * @param path path of the model item to retrieve. This path is relative to the given accessors. No matter if it is a definition or data path (with repeater entry positions)
078     * @param itemAccessors model item accessors where to search the model item
079     * @return the model item
080     * @throws IllegalArgumentException if the given path is null or empty
081     * @throws UndefinedItemPathException if there is no item defined at the given path in given item accessors
082     */
083    public static ModelItem getModelItem(String path, Collection<? extends ModelItemAccessor> itemAccessors) throws IllegalArgumentException, UndefinedItemPathException
084    {
085        if (StringUtils.isEmpty(path))
086        {
087            throw new IllegalArgumentException("Unable to retrieve the model item at the given path. This path is empty.");
088        }
089        
090        String definitionPath = getDefinitionPathFromDataPath(path);
091        
092        ModelItem item = null;
093        for (ModelItemAccessor accessor : itemAccessors)
094        {
095            if (accessor.hasModelItem(definitionPath))
096            {
097                item = accessor.getModelItem(definitionPath);
098                break;
099            }
100        }
101        
102        if (item != null)
103        {
104            return item;
105        }
106        else
107        {
108            String absolutePath = path;
109            if (itemAccessors.size() == 1)
110            {
111                ModelItemAccessor accessor = itemAccessors.iterator().next();
112                if (accessor instanceof ModelItemGroup)
113                {
114                    absolutePath = ((ModelItemGroup) accessor).getPath() + ModelItem.ITEM_PATH_SEPARATOR + path;
115                }
116            }
117            
118            throw new UndefinedItemPathException("Unable to retrieve the model item at path '" + absolutePath + "'. This path is not defined by the model.");
119        }
120    }
121    
122    /**
123     * Retrieves all model items of given accessors.
124     * @param itemAccessors The model items accessors
125     * @return the model items
126     * @throws IllegalArgumentException if some models define a model item with the same name and this model item does not come from a common ancestor 
127     */
128    public static Collection<? extends ModelItem> getModelItems(Collection<? extends ModelItemAccessor> itemAccessors)
129    {
130        Map<String, ModelItem> items = new LinkedHashMap<>();
131
132        for (ModelItemAccessor itemContainer : itemAccessors)
133        {
134            for (ModelItem currentItem : itemContainer.getModelItems())
135            {
136                final String currentItemName = currentItem.getName();
137                if (items.containsKey(currentItemName))
138                {
139                    ModelItem existingItem = items.get(currentItemName);
140                    
141                    if (!currentItem.getModel().equals(existingItem.getModel()))
142                    {
143                        // The definition does not provide from a common ancestor
144                        throw new IllegalArgumentException("The model item '" + currentItemName + "' defined in model '" + currentItem.getModel().getId() + "' is already defined in another model '"
145                                + existingItem.getModel().getId() + "'");
146                    }
147                    continue;
148                }
149
150                items.put(currentItemName, currentItem);
151            }
152        }
153
154        return items.values();
155    }
156    
157    /**
158     * Retrieve the list of successive model items represented by the given paths, indexed by path.
159     * @param paths paths of the model items to retrieve. These paths are relative to the given accessors. No matter if they are definition or data paths (with repeater entry positions)
160     * @param itemAccessors model item accessors where to search the model items
161     * @return the list of successive model items, indexed by path
162     * @throws IllegalArgumentException if one of the given paths is null or empty
163     * @throws UndefinedItemPathException if there is no item defined at one of the given paths in given item accessors
164     */
165    public static Map<String, List<ModelItem>> getAllModelItemsInPaths(Set<String> paths, Collection<? extends ModelItemAccessor> itemAccessors) throws IllegalArgumentException, UndefinedItemPathException
166    {
167        Map<String, List<ModelItem>> result = new HashMap<>();
168        
169        for (String path : paths)
170        {
171            result.put(path, ModelHelper.getAllModelItemsInPath(path, itemAccessors));
172        }
173        
174        return result;
175    }
176    
177    /**
178     * Retrieve the list of successive model items represented by the given path.
179     * @param path path of the model items to retrieve. This path is relative to the given accessors. No matter if it is a definition or data path (with repeater entry positions)
180     * @param itemAccessors model item accessors where to search the model items
181     * @return the list of successive model items
182     * @throws IllegalArgumentException if the given path is null or empty
183     * @throws UndefinedItemPathException if there is no item defined at the given path in given item accessors
184     */
185    public static List<ModelItem> getAllModelItemsInPath(String path, Collection<? extends ModelItemAccessor> itemAccessors) throws IllegalArgumentException, UndefinedItemPathException
186    {
187        if (StringUtils.isEmpty(path))
188        {
189            throw new IllegalArgumentException("Unable to retrieve the model item at the given path. This path is empty.");
190        }
191        
192        String definitionPath = getDefinitionPathFromDataPath(path);
193        
194        for (ModelItemAccessor itemAccessor : itemAccessors)
195        {
196            // If the current accessor has the model item at given path, no need to check in the other accessors
197            // Given containers represent only one whole model, so a path can't refer to different definitions
198            List<ModelItem> modelItemPath = _getAllModelItemsInPath(definitionPath, itemAccessor);
199            if (!modelItemPath.isEmpty())
200            {
201                return modelItemPath;
202            }
203        }
204        
205        // No path has been found in the different containers
206        throw new UndefinedItemPathException("Unable to retrieve the model items at path '" + definitionPath + "'. This path is not defined by the model.");
207    }
208    
209    private static List<ModelItem> _getAllModelItemsInPath(String definitionPath, ModelItemAccessor itemAccessor)
210    {
211        List<ModelItem> definitions = new ArrayList<>();
212
213        String[] pathSegments = StringUtils.split(definitionPath, ModelItem.ITEM_PATH_SEPARATOR);
214        
215        ModelItemAccessor currentModelItemAccessor = itemAccessor;
216        for (int i = 0; i < pathSegments.length; i++)
217        {
218            ModelItem modelItem = currentModelItemAccessor.getChild(pathSegments[i]);
219            if (modelItem != null)
220            {
221                definitions.add(modelItem);
222            }
223            else
224            {
225                return Collections.emptyList();
226            }
227            
228            if (modelItem instanceof ModelItemAccessor)
229            {
230                currentModelItemAccessor = (ModelItemAccessor) modelItem;
231            }
232        }
233        
234        return definitions;
235    }
236    
237
238    /**
239     * Determines if a container of model items contains a model item of the given type
240     * @param container the model item container
241     * @param type the type identifier to find.
242     * @return true if a model item of the given type is found
243     */
244    public static boolean hasModelItemOfType(ModelItemAccessor container, String type)
245    {
246        for (ModelItem childItem : container.getModelItems())
247        {
248            if (type.equals(childItem.getType().getId()))
249            {
250                return true;
251            }
252            else if (childItem instanceof ModelItemGroup)
253            {
254                // recurse on repeater and composites
255                if (hasModelItemOfType((ModelItemGroup) childItem, type))
256                {
257                    return true;
258                }
259            }
260        }
261        
262        return false;
263    }
264    
265    /**
266     * Find all model items of the given type
267     * @param container the model item container
268     * @param type the type identifier to find.
269     * @return the list of {@link ModelItem}s of this type
270     */
271    public static List<ModelItem> findModelItemsByType(ModelItemAccessor container, String type)
272    {
273        List<ModelItem> items = new ArrayList<>();
274        
275        for (ModelItem childItem : container.getModelItems())
276        {
277            if (type.equals(childItem.getType().getId()))
278            {
279                items.add(childItem);
280            }
281            else if (childItem instanceof ModelItemGroup)
282            {
283                // recurse on repeater and composites
284                items.addAll(findModelItemsByType((ModelItemGroup) childItem, type));
285            }
286        }
287        
288        return items;
289    }
290    
291    /**
292     * Retrieves the given dataPath as a definition path (without the repeaterEntry positions)
293     * @param dataPath the data path
294     * @return the definition path
295     */
296    public static String getDefinitionPathFromDataPath(String dataPath)
297    {
298        return dataPath.replaceAll("\\[[0-9]+\\]", "");
299    }
300    
301    /**
302     * Checks if this item is in a group with a switch on
303     * @param modelItem the item to check
304     * @param values all items' values to get switchers' values
305     * @return false if this item is part of a group with a switch to off, true otherwise 
306     */
307    public static boolean isGroupSwitchOn(ModelItem modelItem, Map<String, Object> values)
308    {
309        Pair<Boolean, ElementDefinition> isGroupActive = _isModelItemGroupActive(modelItem.getParent(), values);
310        if (isGroupActive.getKey())
311        {
312            return true;
313        }
314        return modelItem.equals(isGroupActive.getValue());
315    }
316    
317    private static Pair<Boolean, ElementDefinition> _isModelItemGroupActive(ModelItemGroup group, Map<String, Object> values)
318    {
319        if (group == null)
320        {
321            return new ImmutablePair<>(true, null);
322        }
323        
324        ElementDefinition<Boolean> groupSwitch = group.getSwitcher();
325        if (groupSwitch == null)
326        {
327            return _isModelItemGroupActive(group.getParent(), values);
328        }
329        
330        Object value = values.get(groupSwitch.getName());
331        
332        if (value == null)
333        {
334            value = groupSwitch.getDefaultValue();
335        }
336        
337        if (!(value instanceof Boolean))
338        {
339            throw new IllegalStateException("The switcher value of group " + group.getName() + " is null or not a boolean");
340        }
341        
342        return (Boolean) value ? _isModelItemGroupActive(group.getParent(), values) : new ImmutablePair<>(false, groupSwitch);
343    }
344    
345    /**
346     * Retrieves the absolute path of the condition
347     * @param condition the condition
348     * @param relativePath the relative path for computing condition absolute path (path of the model item defining the condition, or path of an evaluated data)
349     * @return the absolute path of the condition
350     * @throws IllegalArgumentException if the path of the condition can not be computed from the given relative path
351     */
352    public static String getDisableConditionAbsolutePath(DisableCondition condition, String relativePath) throws IllegalArgumentException
353    {
354        String[] conditionPathSegments = StringUtils.split(condition.getId(), ModelItem.ITEM_PATH_SEPARATOR);
355        String[] relativePathSegments = StringUtils.split(relativePath, ModelItem.ITEM_PATH_SEPARATOR);
356        
357        int nbParentSegmentInConditionPath = 0;
358        String conditionPathSegment = conditionPathSegments[nbParentSegmentInConditionPath];
359        while ("..".equals(conditionPathSegment))
360        {
361            nbParentSegmentInConditionPath++;
362            conditionPathSegment = conditionPathSegments[nbParentSegmentInConditionPath];
363        }
364        
365        int prefixLength = relativePathSegments.length - (nbParentSegmentInConditionPath + 1);
366        if (prefixLength < 0)
367        {
368            String message = String.format("Unable to retrieve the condition absolute path from condition '%s' and relative path '%s'. The condition path has to many relative parents segments.", condition.getId(), relativePath);
369            throw new IllegalArgumentException(message);
370        }
371        
372        // Take the first segments of relative path
373        String prefix = StringUtils.join(relativePathSegments, ModelItem.ITEM_PATH_SEPARATOR, 0, relativePathSegments.length - (nbParentSegmentInConditionPath + 1));
374        // and the last ones of the condition path
375        String suffix = StringUtils.join(conditionPathSegments, ModelItem.ITEM_PATH_SEPARATOR, nbParentSegmentInConditionPath, conditionPathSegments.length);
376        
377        return StringUtils.isNotBlank(prefix) ? prefix + ModelItem.ITEM_PATH_SEPARATOR + suffix : suffix;
378    }
379    
380    /**
381     * Check if the given model item contains non empty disable conditions 
382     * @param modelItem the model item to check
383     * @return <code>true</code> if a non empty condition has been found, <code>false</code> otherwise
384     */
385    public static boolean hasDisableConditions(ModelItem modelItem)
386    {
387        return hasDisableConditions(modelItem.getDisableConditions());
388    }
389    
390    /**
391     * Check if there is a non empty condition in the given conditions
392     * @param disableConditions the disable conditions to check
393     * @return <code>true</code> if a non empty condition has been found, <code>false</code> otherwise
394     */
395    public static boolean hasDisableConditions(DisableConditions disableConditions)
396    {
397        if (disableConditions == null)
398        {
399            return false;
400        }
401
402        if (!disableConditions.getConditions().isEmpty())
403        {
404            // a condition has been found
405            return true;
406        }
407        
408        return disableConditions.getSubConditions()
409                                .stream()
410                                // search for a non empty sub condition
411                                .anyMatch(ModelHelper::hasDisableConditions);
412    }
413
414    /**
415     * Validates the given value
416     * @param definition The definition to use to validate the value
417     * @param value the value to validate
418     * @return the structure with errors information if the validation failed.
419     */
420    public static ValidationResult validateValue(ElementDefinition definition, Object value)
421    {
422        return validateValue(definition, value, true);
423    }
424    
425    /**
426     * Validates the given value
427     * @param definition The definition to use to validate the value
428     * @param value the value to validate
429     * @param checkEnumerated <code>true</code> true to make sure that the item with an enumerator has its value in the enumerated values
430     * @return the structure with errors information if the validation failed.
431     * TODO NEWATTRIBUTEAPI RUNTIME-2897: remove this method to always check enumerator when validating a value 
432     */
433    public static ValidationResult validateValue(ElementDefinition definition, Object value, boolean checkEnumerated)
434    {
435        ValidationResult result = new ValidationResult();
436
437        ElementType type = definition.getType();
438        if (value != null && !type.isCompatible(value))
439        {
440            result.addError(new I18nizableText("plugin.core", "PLUGINS_CORE_ELEMENT_DEFINITION_VALUE_NOT_ALLOWED"));
441        }
442        else
443        {
444            Validator validator = definition.getValidator();
445            if (validator != null)
446            {
447                result.addResult(validator.validate(value));
448            }
449            
450            if (checkEnumerated)
451            {
452                if (definition.isMultiple())
453                {
454                    Object[] values = (Object[]) value;
455                    if (values != null)
456                    {  
457                        for (Object singleValue : values)
458                        {
459                            result.addResult(_validateEnumeration(definition, singleValue));
460                        }
461                    }
462                    
463                }
464                else
465                {
466                    result.addResult(_validateEnumeration(definition, value));
467                }
468            }
469        }
470        
471        return result;
472    }
473    
474    private static ValidationResult _validateEnumeration(ElementDefinition definition, Object value)
475    {
476        ValidationResult result = new ValidationResult();
477        
478        if (_checkValueEnumeration(value))
479        {
480            // Make sure that the item with an enumerator has its value in the enumerated values
481            Enumerator<Object> enumerator = definition.getEnumerator();
482            if (enumerator != null)
483            {
484                try
485                {
486                    I18nizableText entry = enumerator.getEntry(value);
487                    if (entry == null)
488                    {
489                        result.addError(new I18nizableText("plugin.core", "PLUGINS_CORE_ELEMENT_DEFINITION_VALUE_NOT_ALLOWED"));
490                    }
491                }
492                catch (Exception e)
493                {
494                    __LOGGER.warn("An error occured while checking enumerated value '{}' for '{}'", value, enumerator, e);
495                    result.addError(new I18nizableText("plugin.core", "PLUGINS_CORE_ELEMENT_DEFINITION_VALUE_LED_TO_EXCEPTION"));
496                }
497            }
498        }
499        
500        return result;
501    }
502    
503    private static boolean _checkValueEnumeration(Object value)
504    {
505        if (value == null)
506        {
507            return false;
508        }
509        else
510        {
511            return value instanceof String ? StringUtils.isNotEmpty((String) value) : true;
512        }
513    }
514    
515    /**
516     * Stores a configuration and the name of the plugin in which is declared the configuration
517     * @param configuration the configuration itself
518     * @param pluginName the name of the plugin in which is declared the configuration
519     */
520    public record ConfigurationAndPluginName(Configuration configuration, String pluginName) { /* empty */ }
521
522    /**
523     * Parse an i18n text.
524     * @param configurationAndPluginName the configuration to use.
525     * @param name the child name.
526     * @return the i18n text.
527     * @throws ConfigurationException if the configuration is not valid.
528     */
529    public static I18nizableText parseI18nizableText(ConfigurationAndPluginName configurationAndPluginName, String name) throws ConfigurationException
530    {
531        return parseI18nizableText(configurationAndPluginName, name, StringUtils.EMPTY);
532    }
533    
534    /**
535     * Parse an i18n text.
536     * @param configurationAndPluginName the configuration to use.
537     * @param name the child name.
538     * @param defaultValue the default value if no present
539     * @return the i18n text.
540     * @throws ConfigurationException if the configuration is not valid.
541     */
542    public static I18nizableText parseI18nizableText(ConfigurationAndPluginName configurationAndPluginName, String name, String defaultValue) throws ConfigurationException
543    {
544        return I18nizableText.parseI18nizableText(configurationAndPluginName.configuration().getChild(name), "plugin." + configurationAndPluginName.pluginName(), defaultValue);
545    }
546    
547    /**
548     * Parse an i18n text.
549     * @param configurationAndPluginName the configuration to use.
550     * @param name the child name.
551     * @param defaultValue the default value if no present
552     * @return the i18n text.
553     * @throws ConfigurationException if the configuration is not valid.
554     */
555    public static I18nizableText parseI18nizableText(ConfigurationAndPluginName configurationAndPluginName, String name, I18nizableText defaultValue) throws ConfigurationException
556    {
557        return I18nizableText.parseI18nizableText(configurationAndPluginName.configuration().getChild(name), "plugin." + configurationAndPluginName.pluginName(), defaultValue);
558    }
559}