001/*
002 *  Copyright 2017 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.cms.contenttype.validation;
017
018import java.util.ArrayList;
019import java.util.Arrays;
020import java.util.List;
021import java.util.Map;
022import java.util.Optional;
023
024import org.apache.avalon.framework.configuration.Configurable;
025import org.apache.avalon.framework.configuration.Configuration;
026import org.apache.avalon.framework.configuration.ConfigurationException;
027import org.apache.commons.lang3.StringUtils;
028
029import org.ametys.cms.contenttype.AttributeDefinition;
030import org.ametys.cms.contenttype.ContentValidator;
031import org.ametys.cms.repository.Content;
032import org.ametys.plugins.repository.data.holder.impl.DataHolderHelper;
033import org.ametys.plugins.repository.data.holder.values.SynchronizableRepeater;
034import org.ametys.plugins.repository.data.holder.values.SynchronizationContext;
035import org.ametys.plugins.repository.model.CompositeDefinition;
036import org.ametys.plugins.repository.model.RepeaterDefinition;
037import org.ametys.runtime.i18n.I18nizableText;
038import org.ametys.runtime.model.ElementDefinition;
039import org.ametys.runtime.model.ModelItem;
040import org.ametys.runtime.model.ModelItemGroup;
041import org.ametys.runtime.model.View;
042import org.ametys.runtime.model.type.ElementType;
043import org.ametys.runtime.parameter.Errors;
044
045/**
046 * Base {@link ContentValidator} validating the content by comparing one or more couples of values.
047 * For each couple of values, the identified max value should be greater than or equal to the identified min value.
048 * 
049 * @param <T> The type tested by the validator.
050 */
051public abstract class AbstractIntervalValidator<T> extends AbstractContentValidator implements Configurable
052{
053    private String _minDefinitionPath;
054    private String _maxDefinitionPath;
055    private boolean _mandatory;
056    
057    @Override
058    public void configure(Configuration configuration) throws ConfigurationException
059    {
060        _minDefinitionPath = configureMinPath(configuration);
061        _maxDefinitionPath = configureMaxPath(configuration);
062        _mandatory = configuration.getChild("mandatory", true).getValueAsBoolean(false);
063    }
064    
065    @Override
066    public void validate(Content content, Errors errors)
067    {
068        ElementDefinition minDefinition = (ElementDefinition) content.getDefinition(_minDefinitionPath);
069        ElementDefinition maxDefinition = (ElementDefinition) content.getDefinition(_maxDefinitionPath);
070        
071        _checkAttributeDefinitions(minDefinition, maxDefinition);
072        
073        Object minValues = content.getValue(_minDefinitionPath, true);
074        Object maxValues = content.getValue(_maxDefinitionPath, true);
075        
076        _validateInterval(content, errors, minDefinition, maxDefinition, minValues, maxValues);
077    }
078    
079    @SuppressWarnings("unchecked")
080    private List<T> _getValuesAsList(Object value)
081    {
082        if (value == null)
083        {
084            List<T> result = new ArrayList<>();
085            result.add(null);
086            return result;
087        }
088        
089        return value instanceof List ? (List<T>) value : value.getClass().isArray() ? Arrays.asList((T[]) value) : List.of((T) value);
090    }
091    
092    /**
093     * Validate the interval values of a content
094     * @param content The content
095     * @param errors The list of errors to fill
096     * @param minDefinition The definition for the min value
097     * @param maxDefinition The definition for the max value
098     * @param minValues The list of min values
099     * @param maxValues The list of max values
100     */
101    protected void _validateInterval(Content content, Errors errors, ElementDefinition minDefinition, ElementDefinition maxDefinition, Object minValues, Object maxValues)
102    {
103        List<T> listMinValues = _getValuesAsList(minValues);
104        List<T> listMaxValues = _getValuesAsList(maxValues);
105                
106        if (listMinValues.size() != listMaxValues.size())
107        {
108            if (getLogger().isDebugEnabled())
109            {
110                getLogger().debug("Could not process with the content validation: did not found a min/max couples for each values.");
111            }
112            
113            errors.addError(new I18nizableText("plugin.cms", "PLUGINS_CMS_CONTENT_VALIDATOR_NUMBER_INTERVAL_SIZEERROR"));
114            return;
115        }
116        
117        for (int i = 0; i < listMinValues.size(); i++)
118        {
119            T min = listMinValues.get(i);
120            T max = listMaxValues.get(i);
121            
122            if (_mandatory && (min != null ^ max != null))
123            {
124                I18nizableText emptyLabel = min == null ? minDefinition.getLabel() : maxDefinition.getLabel();
125                if (getLogger().isDebugEnabled())
126                {
127                    getLogger().debug("Content '" + content.getId() + "' is invalid : " + emptyLabel + " is empty but is part of a mandatory interval");
128                }
129                
130                addErrorEmpty(errors, minDefinition, maxDefinition, emptyLabel);
131            }
132            else if (min != null && max != null && isLessThan(max, min))
133            {
134                if (getLogger().isDebugEnabled())
135                {
136                    getLogger().debug("Content '" + content.getId() + "' is invalid : " + min + " [" + _minDefinitionPath + "] is greater than " + max + " [" + _maxDefinitionPath + "]");
137                }
138                
139                addIntervalError(errors, minDefinition, maxDefinition, min, max);
140            }
141        }
142    }
143
144    @Override
145    public void validate(Content content, Map<String, Object> values, View view, Errors errors)
146    {
147        AttributeDefinition minDefinition = (AttributeDefinition) content.getDefinition(_minDefinitionPath);
148        AttributeDefinition maxDefinition = (AttributeDefinition) content.getDefinition(_maxDefinitionPath);
149        
150        _checkAttributeDefinitions(minDefinition, maxDefinition);
151        
152        Object minValues;
153        Object maxValues;
154        
155        if (minDefinition.canWrite(content))
156        {
157            minValues = _getValues(content, values, _minDefinitionPath, Optional.of(StringUtils.EMPTY), null);
158        }
159        else
160        {
161            minValues = content.getValue(_minDefinitionPath, true);
162        }
163
164        if (maxDefinition.canWrite(content))
165        {
166            maxValues = _getValues(content, values, _maxDefinitionPath, Optional.of(StringUtils.EMPTY), null);
167        }
168        else
169        {
170            maxValues = content.getValue(_maxDefinitionPath , true);
171        }
172
173        _validateInterval(content, errors, minDefinition, maxDefinition, minValues, maxValues);
174    }
175    
176    private void _checkAttributeDefinitions (ElementDefinition minDefinition, ElementDefinition maxDefinition)
177    {
178        if (minDefinition == null)
179        {
180            throw new IllegalArgumentException("Unable to validate content: the path '" + _minDefinitionPath + "' refers to a non existing attribute for content type " + getContentType().getId());
181        }
182        if (maxDefinition == null)
183        {
184            throw new IllegalArgumentException("Unable to validate content: the path '" + _maxDefinitionPath + "' refers to a non existing attribute for content type " + getContentType().getId());
185        }
186        
187        if (!isSupportedType(minDefinition.getType()))
188        {
189            throw new IllegalArgumentException("Unable to validate content: the attribute to path '" + _minDefinitionPath + "' (" + minDefinition.getType() + ") is not the expected type");
190        }
191        
192        if (!isSupportedType(maxDefinition.getType()))
193        {
194            throw new IllegalArgumentException("Unable to validate content: the attribute to path '" + _maxDefinitionPath + "' (" + maxDefinition.getType() + ") is not the expected type");
195        }
196    }
197
198    @SuppressWarnings("unchecked")
199    private List<T> _getValues(Content content, Map<String, Object> values, String definitionPath, Optional<String> oldPrefix, ModelItemGroup parentDefinition)
200    {
201        String[] pathSegments = StringUtils.split(definitionPath, ModelItem.ITEM_PATH_SEPARATOR);
202        
203        if (pathSegments.length == 0)
204        {
205            return null;
206        }
207        
208        String fieldName = pathSegments[0];
209
210        ModelItem modelItem = parentDefinition == null ? content.getDefinition(fieldName) : parentDefinition.getChild(fieldName);
211        
212        Object valueFromMap = values.get(fieldName);
213        Optional<String> oldDataPath = oldPrefix.map(p -> p + fieldName);
214        Object value = DataHolderHelper.getValueFromSynchronizableValue(valueFromMap, content, modelItem, oldDataPath, SynchronizationContext.newInstance());
215        if (value == null)
216        {
217            return null;
218        }
219            
220        if (pathSegments.length == 1)
221        {
222            if (!(modelItem instanceof ElementDefinition))
223            {
224                throw new IllegalArgumentException("Last path segment should correspond to an ElementType");
225            }
226            
227            ElementDefinition definition = (ElementDefinition) modelItem;
228            
229            if (isSupportedType(definition.getType()))
230            {
231                return value.getClass().isArray() ? Arrays.asList((T[]) value) : List.of((T) value);
232            }
233            else
234            {
235                // Should never happen (this was checked before)
236                throw new IllegalArgumentException("Attribute '" + fieldName + "' (" + definition.getType() + ") is not the expected type");
237            }
238        }
239        else
240        {
241            String remainingPath = StringUtils.join(pathSegments, ModelItem.ITEM_PATH_SEPARATOR, 1, pathSegments.length);
242            
243            if (modelItem instanceof CompositeDefinition)
244            {
245                Optional<String> updatedOldPrefix = oldPrefix.map(p -> p + fieldName + ModelItem.ITEM_PATH_SEPARATOR);
246                return _getValues(content, (Map<String, Object>) value, remainingPath, updatedOldPrefix, (CompositeDefinition) modelItem);
247            }
248            else if (modelItem instanceof RepeaterDefinition)
249            {
250                List<Map<String, Object>> entries = value instanceof List ? (List<Map<String, Object>>) value : ((SynchronizableRepeater) value).getEntries();
251                
252                List<T> result = new ArrayList<>();
253                
254                for (int i = 0; i < entries.size(); i++)
255                {
256                    Optional<String> updatedOldPrefix = _getRepeaterUpdatedOldPrefix(fieldName, oldPrefix, value, i);
257                    List<T> entryValues = _getValues(content, entries.get(i), remainingPath, updatedOldPrefix, (RepeaterDefinition) modelItem);
258
259                    if (entryValues != null)
260                    {
261                        result.addAll(entryValues);
262                    }
263                }
264                
265                return result;
266            }
267            else
268            {
269                throw new IllegalArgumentException("Inner path segments should correspond to an ModelItemGroup");
270            }
271        }
272    }
273
274    private Optional<String> _getRepeaterUpdatedOldPrefix(String fieldName, Optional<String> oldPrefix, Object value, int i)
275    {
276        if (value instanceof SynchronizableRepeater)
277        {
278            SynchronizableRepeater syncRepeater = (SynchronizableRepeater) value;
279            Optional<Integer> previousPosition = syncRepeater.getPreviousPosition(i);
280            if (previousPosition.isPresent())
281            {
282                return oldPrefix.map(p -> p + fieldName + "[" + previousPosition.get() + "]" + ModelItem.ITEM_PATH_SEPARATOR);
283            }
284        }
285        
286        return Optional.empty();
287    }
288    
289    /**
290     * Get the metadata min path from the configuration
291     * @param configuration The configuration
292     * @return The metadata min path
293     * @throws ConfigurationException If an error occurs
294     */
295    protected abstract String configureMinPath(Configuration configuration) throws ConfigurationException;
296    
297    /**
298     * Get the metadata max path from the configuration
299     * @param configuration The configuration
300     * @return The metadata max path
301     * @throws ConfigurationException If an error occurs
302     */
303    protected abstract String configureMaxPath(Configuration configuration) throws ConfigurationException;
304
305    /**
306     * Test if the first number is less than the second one.
307     * @param n1 The first number to compare.
308     * @param n2 The second number to compare.
309     * @return true if the first number is less than the first, false otherwise.
310     */
311    protected abstract boolean isLessThan(T n1, T n2);
312    
313    /**
314     * Test if the attribute type is one expected by the validator
315     * @param type The attribute type
316     * @return True if the type is supported
317     */
318    protected abstract boolean isSupportedType(ElementType type);
319    
320
321    /**
322     * Add an error when the max value is less than the min value
323     * @param errors The list of errors
324     * @param minDefinition The min definition
325     * @param maxDefinition The max definition
326     * @param min The min value
327     * @param max The max value
328     */
329    protected abstract void addIntervalError(Errors errors, ElementDefinition minDefinition, ElementDefinition maxDefinition, T min, T max);
330
331    /**
332     * Add an error when the max or the min value is empty but the interval is mandatory
333     * @param errors The list of errors
334     * @param minDefinition The min definition
335     * @param maxDefinition The max definition
336     * @param emptyLabel The label of the empty field
337     */
338    protected abstract void addErrorEmpty(Errors errors, ElementDefinition minDefinition, ElementDefinition maxDefinition, I18nizableText emptyLabel);
339}