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