package org.molgenis.data.validation.meta; import com.google.common.collect.Iterables; import org.hibernate.validator.constraints.impl.EmailValidator; import org.molgenis.data.DataService; import org.molgenis.data.EntityManager; import org.molgenis.data.MolgenisDataException; import org.molgenis.data.Sort; import org.molgenis.data.meta.AttributeType; import org.molgenis.data.meta.NameValidator; import org.molgenis.data.meta.model.Attribute; import org.molgenis.data.meta.model.EntityType; import org.molgenis.data.validation.ConstraintViolation; import org.molgenis.data.validation.MolgenisValidationException; import org.molgenis.util.EntityUtils; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Component; import java.net.URI; import java.net.URISyntaxException; import java.util.EnumMap; import java.util.EnumSet; import java.util.List; import java.util.Objects; import static java.lang.String.format; import static java.util.Objects.requireNonNull; import static org.molgenis.data.meta.AttributeType.*; import static org.molgenis.data.meta.model.AttributeMetadata.ATTRIBUTE_META_DATA; import static org.molgenis.data.meta.model.EntityTypeMetadata.ENTITY_TYPE_META_DATA; import static org.molgenis.data.meta.model.PackageMetadata.PACKAGE; import static org.molgenis.data.support.AttributeUtils.getValidIdAttributeTypes; import static org.molgenis.data.support.EntityTypeUtils.isSingleReferenceType; /** * Attribute metadata validator */ @Component public class AttributeValidator { public enum ValidationMode { ADD, UPDATE } private final DataService dataService; private final EntityManager entityManager; private final EmailValidator emailValidator; @Autowired public AttributeValidator(DataService dataService, EntityManager entityManager) { this.dataService = requireNonNull(dataService); this.entityManager = requireNonNull(entityManager); this.emailValidator = new EmailValidator(); } public void validate(Attribute attr, ValidationMode validationMode) { validateName(attr); validateDefaultValue(attr); validateParent(attr); validateChildren(attr); switch (validationMode) { case ADD: validateAdd(attr); break; case UPDATE: Attribute currentAttr = dataService .findOneById(ATTRIBUTE_META_DATA, attr.getIdentifier(), Attribute.class); validateUpdate(attr, currentAttr); break; default: throw new RuntimeException(format("Unknown attribute validation mode [%s]", validationMode.toString())); } } private static void validateParent(Attribute attr) { if (attr.getParent() != null) { if (attr.getParent().getDataType() != COMPOUND) { throw new MolgenisDataException( format("Parent attribute [%s] of attribute [%s] is not of type compound", attr.getParent().getName(), attr.getName())); } } } private static void validateChildren(Attribute attr) { boolean childrenIsNullOrEmpty = attr.getChildren() == null || Iterables.isEmpty(attr.getChildren()); if (!childrenIsNullOrEmpty && attr.getDataType() != COMPOUND) { throw new MolgenisDataException( format("Attribute [%s] is not of type COMPOUND and can therefor not have children", attr.getName())); } } private static void validateAdd(Attribute newAttr) { // mappedBy validateMappedBy(newAttr, newAttr.getMappedBy()); // orderBy validateOrderBy(newAttr, newAttr.getOrderBy()); } private static void validateUpdate(Attribute newAttr, Attribute currentAttr) { // data type AttributeType currentDataType = currentAttr.getDataType(); AttributeType newDataType = newAttr.getDataType(); if (!Objects.equals(currentDataType, newDataType)) { validateUpdateDataType(currentDataType, newDataType); if (newAttr.isInversedBy()) { throw new MolgenisDataException( format("Attribute data type change not allowed for bidirectional attribute [%s]", newAttr.getName())); } } // orderBy Sort currentOrderBy = currentAttr.getOrderBy(); Sort newOrderBy = newAttr.getOrderBy(); if (!Objects.equals(currentOrderBy, newOrderBy)) { validateOrderBy(newAttr, newOrderBy); } // note: mappedBy is a readOnly attribute, no need to verify for updates } void validateDefaultValue(Attribute attr) { String value = attr.getDefaultValue(); if (attr.getDefaultValue() != null) { if (attr.isUnique()) { throw new MolgenisDataException("Unique attribute " + attr.getName() + " cannot have default value"); } if (attr.getExpression() != null) { throw new MolgenisDataException("Computed attribute " + attr.getName() + " cannot have default value"); } AttributeType fieldType = attr.getDataType(); if (fieldType == AttributeType.XREF || fieldType == AttributeType.MREF) { throw new MolgenisDataException("Attribute " + attr.getName() + " cannot have default value since specifying a default value for XREF and MREF data types is not yet supported."); } if (fieldType.getMaxLength() != null && value.length() > fieldType.getMaxLength()) { throw new MolgenisDataException( "Default value for attribute [" + attr.getName() + "] exceeds the maximum length for datatype " + attr.getDataType().name()); } if (fieldType == AttributeType.EMAIL) { checkEmail(value); } if (fieldType == AttributeType.HYPERLINK) { checkHyperlink(value); } if (fieldType == AttributeType.ENUM) { checkEnum(attr, value); } // Get typed value to check if the value is of the right type. try { EntityUtils.getTypedValue(value, attr, entityManager); } catch (NumberFormatException e) { throw new MolgenisValidationException(new ConstraintViolation( format("Invalid default value [%s] for data type [%s]", value, attr.getDataType()))); } } } private void checkEmail(String value) { if (!emailValidator.isValid(value, null)) { throw new MolgenisDataException("Default value [" + value + "] is not a valid email address"); } } private static void checkEnum(Attribute attr, String value) { if (value != null) { List<String> enumOptions = attr.getEnumOptions(); if (!enumOptions.contains(value)) { throw new MolgenisDataException( "Invalid default value [" + value + "] for enum [" + attr.getName() + "] value must be one of " + enumOptions.toString()); } } } private static void checkHyperlink(String value) { try { new URI(value); } catch (URISyntaxException e) { throw new MolgenisDataException("Default value [" + value + "] is not a valid hyperlink."); } } private static void validateName(Attribute attr) { // validate entity name (e.g. illegal characters, length) String name = attr.getName(); if (!name.equals(ATTRIBUTE_META_DATA) && !name.equals(ENTITY_TYPE_META_DATA) && !name.equals(PACKAGE)) { try { NameValidator.validateName(attr.getName()); } catch (MolgenisDataException e) { throw new MolgenisValidationException(new ConstraintViolation(e.getMessage())); } } } /** * Validate whether the mappedBy attribute is part of the referenced entity. * * @param attr attribute * @param mappedByAttr mappedBy attribute * @throws MolgenisDataException if mappedBy is an attribute that is not part of the referenced entity */ private static void validateMappedBy(Attribute attr, Attribute mappedByAttr) { if (mappedByAttr != null) { if (!isSingleReferenceType(mappedByAttr)) { throw new MolgenisDataException( format("Invalid mappedBy attribute [%s] data type [%s].", mappedByAttr.getName(), mappedByAttr.getDataType())); } Attribute refAttr = attr.getRefEntity().getAttribute(mappedByAttr.getName()); if (refAttr == null) { throw new MolgenisDataException( format("mappedBy attribute [%s] is not part of entity [%s].", mappedByAttr.getName(), attr.getRefEntity().getName())); } } } /** * Validate whether the attribute names defined by the orderBy attribute point to existing attributes in the * referenced entity. * * @param attr attribute * @param orderBy orderBy of attribute * @throws MolgenisDataException if orderBy contains attribute names that do not exist in the referenced entity. */ private static void validateOrderBy(Attribute attr, Sort orderBy) { if (orderBy != null) { EntityType refEntity = attr.getRefEntity(); if (refEntity != null) { for (Sort.Order orderClause : orderBy) { String refAttrName = orderClause.getAttr(); if (refEntity.getAttribute(refAttrName) == null) { throw new MolgenisDataException( format("Unknown entity [%s] attribute [%s] referred to by entity [%s] attribute [%s] sortBy [%s]", refEntity.getName(), refAttrName, attr.getEntityType().getName(), attr.getName(), orderBy.toSortString())); } } } } } private static void validateUpdateDataType(AttributeType currentDataType, AttributeType newDataType) { EnumSet<AttributeType> allowedDatatypes = DATA_TYPE_ALLOWED_TRANSITIONS.get(currentDataType); if (!allowedDatatypes.contains(newDataType)) { throw new MolgenisDataException( format("Attribute data type update from [%s] to [%s] not allowed, allowed types are %s", currentDataType.toString(), newDataType.toString(), allowedDatatypes.toString())); } } private static EnumMap<AttributeType, EnumSet<AttributeType>> DATA_TYPE_ALLOWED_TRANSITIONS; static { // transitions to EMAIL and HYPERLINK not allowed because existing values can not be validated // transitions to CATEGORICAL_MREF and MREF not allowed because junction tables updated not implemented // transitions to FILE not allowed because associated file in FileStore not created/removed, see github issue https://github.com/molgenis/molgenis/issues/3217 DATA_TYPE_ALLOWED_TRANSITIONS = new EnumMap<>(AttributeType.class); EnumSet<AttributeType> allowedIdAttributeTypes = getValidIdAttributeTypes(); // TRUE and FALSE can either be expressed in string or 0 and 1 // Postgres does not support boolean to bigint or double precision (LONG and DECIMAL) DATA_TYPE_ALLOWED_TRANSITIONS.put(BOOL, EnumSet.of(STRING, TEXT, INT)); // DATE and DATE_TIME can only be converted to STRING and TEXT types DATA_TYPE_ALLOWED_TRANSITIONS.put(DATE, EnumSet.of(STRING, TEXT, DATE_TIME)); DATA_TYPE_ALLOWED_TRANSITIONS.put(DATE_TIME, EnumSet.of(STRING, TEXT, DATE)); DATA_TYPE_ALLOWED_TRANSITIONS.put(DECIMAL, EnumSet.of(STRING, TEXT, INT, LONG, ENUM)); DATA_TYPE_ALLOWED_TRANSITIONS.put(INT, EnumSet.of(STRING, TEXT, DECIMAL, LONG, BOOL, ENUM)); DATA_TYPE_ALLOWED_TRANSITIONS.put(LONG, EnumSet.of(STRING, TEXT, INT, DECIMAL, ENUM)); // EMAIL and HYPERLINK can never be anything else then STRING or TEXT compatible DATA_TYPE_ALLOWED_TRANSITIONS.put(EMAIL, EnumSet.of(STRING, TEXT)); DATA_TYPE_ALLOWED_TRANSITIONS.put(HYPERLINK, EnumSet.of(STRING, TEXT)); // If you have JS only in your HTML attribute, you can also change it to SCRIPT DATA_TYPE_ALLOWED_TRANSITIONS.put(HTML, EnumSet.of(STRING, TEXT, SCRIPT)); // CATEGORICAL and XREF can be converted to all the allowed ID attribute types, and to eachother // EMAIL and HYPERLINK are excluded, we are unable to validate the format DATA_TYPE_ALLOWED_TRANSITIONS.put(CATEGORICAL, EnumSet.of(STRING, INT, LONG, XREF)); DATA_TYPE_ALLOWED_TRANSITIONS.put(XREF, EnumSet.of(STRING, INT, LONG, CATEGORICAL)); // Allow transition between types that already have a junction table DATA_TYPE_ALLOWED_TRANSITIONS.put(MREF, EnumSet.of(CATEGORICAL_MREF)); DATA_TYPE_ALLOWED_TRANSITIONS.put(CATEGORICAL_MREF, EnumSet.of(MREF)); // SCRIPT is an algorithm, which can not be anything else then STRING or TEXT DATA_TYPE_ALLOWED_TRANSITIONS.put(SCRIPT, EnumSet.of(STRING, TEXT)); DATA_TYPE_ALLOWED_TRANSITIONS .put(STRING, EnumSet.of(BOOL, DATE, DATE_TIME, DECIMAL, INT, LONG, HTML, SCRIPT, TEXT, ENUM, COMPOUND)); DATA_TYPE_ALLOWED_TRANSITIONS .put(TEXT, EnumSet.of(BOOL, DATE, DATE_TIME, DECIMAL, INT, LONG, HTML, SCRIPT, STRING, ENUM, COMPOUND)); DATA_TYPE_ALLOWED_TRANSITIONS.put(ENUM, EnumSet.of(STRING, INT, LONG, TEXT)); // STRING only, because STRING can be converted to almost everything else DATA_TYPE_ALLOWED_TRANSITIONS.put(COMPOUND, EnumSet.of(STRING)); // ONE_TO_MANY and FILE can never be anything else DATA_TYPE_ALLOWED_TRANSITIONS.put(ONE_TO_MANY, EnumSet.noneOf(AttributeType.class)); DATA_TYPE_ALLOWED_TRANSITIONS.put(FILE, EnumSet.noneOf(AttributeType.class)); // Excluded MREF and CATEGORICAL_MREF because transition to a type with a junction table is not possible at the moment EnumSet<AttributeType> referenceTypes = EnumSet.of(XREF, CATEGORICAL); // Every type that is listed as a valid ID attribute type is allowed to be converted to an XREF and CATEGORICAL DATA_TYPE_ALLOWED_TRANSITIONS.keySet().stream().filter(type -> allowedIdAttributeTypes.contains(type)) .forEach(type -> DATA_TYPE_ALLOWED_TRANSITIONS.get(type).addAll(referenceTypes)); } }