From 13bac8cb7330196ca80be0f8f60c99d1fd2a5cfe Mon Sep 17 00:00:00 2001 From: freddyDOTCMS <147462678+freddyDOTCMS@users.noreply.github.com> Date: Wed, 30 Oct 2024 12:29:11 -0600 Subject: [PATCH] Issue 30277 create api factory methods to insert in the unique fields table (#30466) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit We use a Lucene query to check for unique field values, but this approach has issues due to a race condition. The ElasticSearch data isn’t updated immediately after the Contentlet database update, so if another Contentlet with the same unique values is saved before ElasticSearch is refreshed, duplicates can occur. This issue is particularly likely during high-volume imports, such as when importing hundreds of Contentlets at once. I was able to reproduce this error locally by directly creating Contentlets via the API endpoint, sending 100 simultaneous requests using Postman. The new approach for validating unique fields involves using an additional table. This table will have a primary key created from a hash, which combines the following elements: the ContentType's variable name, language, Field's variable name, Field's value, and—if the uniquePerSite field variable is set to TRUE—the site ID. ### Proposed Changes * We don't want to remove the old approach with the ES validation, we want to keep it if we need to come back to it to avoid any unexpected problem with the new approach, so I am going to create a Strategy to switch between this 2 approaches using a config property. https://github.com/dotCMS/core/pull/30466/files#diff-fa1ceaa19618a6b2bbc30e24c6f930b4971f417db50babb748c2e2837ba9eb82R237 https://github.com/dotCMS/core/pull/30466/files#diff-fa1ceaa19618a6b2bbc30e24c6f930b4971f417db50babb748c2e2837ba9eb82R7654 https://github.com/dotCMS/core/pull/30466/files#diff-445f8d01aa4de058eaaf883e573d17ef1123b1e74a9a98120ac156b78f4c6522R17 * For our new Extra table approach we ned to save the register in the extra table with the Contentlet's ID, the Contentlet's ID is not used for the hash calculation but is going to be sued later to clean up the table when a Contentlet is deleted, that is why a afterSaved method is included in the Strategy, this method is going to be called after saved the Contentlet https://github.com/dotCMS/core/pull/30466/files#diff-fa1ceaa19618a6b2bbc30e24c6f930b4971f417db50babb748c2e2837ba9eb82R5528 - I am going to remove all the ES validation code, and add it in the new ESUniqueFieldValidationStrategy class https://github.com/dotCMS/core/pull/30466/files#diff-fa1ceaa19618a6b2bbc30e24c6f930b4971f417db50babb748c2e2837ba9eb82L7635-L7725 https://github.com/dotCMS/core/pull/30466/files#diff-e3b9fd8560a668db0c37d00715b112bfa3fdfddc778b5dd1c4bac3b73f1fdd72R45 - I need to create a new ExtraTableUniqueFieldValidationStrategy for the Extra table validation Strategy implementation https://github.com/dotCMS/core/pull/30466/files#diff-efdeeb8f5e148f60415043882bbe26bafc2d643378abdb58e7b8fc944087fe52R40 - Create a Singleton util Class to provide all the method to work with the new extra table, I don't create a Factory because in this case really we don't have a API to match the Factory https://github.com/dotCMS/core/pull/30466/files#diff-b0aed2eddcc36be2a2e2f46624345a83c8698db238fe4dc228c0fb13a11e344fR16 ### Checklist - [ ] Tests - [ ] Translations - [ ] Security Implications Contemplated (add notes if applicable) ### Additional Info ** any additional useful context or info ** ### Screenshots Original | Updated :-------------------------:|:-------------------------: ** original screenshot ** | ** updated screenshot ** --------- Co-authored-by: Will Ezell --- .../business/ESContentletAPIImpl.java | 147 +-- .../UniqueFieldValueDuplicatedException.java | 24 + .../ESUniqueFieldValidationStrategy.java | 180 +++ .../UniqueFieldValidationStrategy.java | 94 ++ ...UniqueFieldValidationStrategyResolver.java | 42 + .../DBUniqueFieldValidationStrategy.java | 191 +++ .../extratable/UniqueFieldCriteria.java | 166 +++ .../extratable/UniqueFieldDataBaseUtil.java | 74 ++ .../src/test/java/com/dotcms/MainSuite2b.java | 10 +- .../business/ESContentletAPIImplTest.java | 1155 ++++++++++++++--- .../DBUniqueFieldValidationStrategyTest.java | 845 ++++++++++++ .../UniqueFieldDataBaseUtilTest.java | 117 ++ 12 files changed, 2761 insertions(+), 284 deletions(-) create mode 100644 dotCMS/src/main/java/com/dotcms/contenttype/business/UniqueFieldValueDuplicatedException.java create mode 100644 dotCMS/src/main/java/com/dotcms/contenttype/business/uniquefields/ESUniqueFieldValidationStrategy.java create mode 100644 dotCMS/src/main/java/com/dotcms/contenttype/business/uniquefields/UniqueFieldValidationStrategy.java create mode 100644 dotCMS/src/main/java/com/dotcms/contenttype/business/uniquefields/UniqueFieldValidationStrategyResolver.java create mode 100644 dotCMS/src/main/java/com/dotcms/contenttype/business/uniquefields/extratable/DBUniqueFieldValidationStrategy.java create mode 100644 dotCMS/src/main/java/com/dotcms/contenttype/business/uniquefields/extratable/UniqueFieldCriteria.java create mode 100644 dotCMS/src/main/java/com/dotcms/contenttype/business/uniquefields/extratable/UniqueFieldDataBaseUtil.java create mode 100644 dotcms-integration/src/test/java/com/dotcms/contenttype/business/uniquefields/extratable/DBUniqueFieldValidationStrategyTest.java create mode 100644 dotcms-integration/src/test/java/com/dotcms/contenttype/business/uniquefields/extratable/UniqueFieldDataBaseUtilTest.java diff --git a/dotCMS/src/main/java/com/dotcms/content/elasticsearch/business/ESContentletAPIImpl.java b/dotCMS/src/main/java/com/dotcms/content/elasticsearch/business/ESContentletAPIImpl.java index 8253b98a295e..a8bc97ab39b2 100644 --- a/dotCMS/src/main/java/com/dotcms/content/elasticsearch/business/ESContentletAPIImpl.java +++ b/dotCMS/src/main/java/com/dotcms/content/elasticsearch/business/ESContentletAPIImpl.java @@ -1,9 +1,11 @@ package com.dotcms.content.elasticsearch.business; +import com.dotcms.analytics.content.ContentAnalyticsAPI; import com.dotcms.api.system.event.ContentletSystemEventUtil; import com.dotcms.api.web.HttpServletRequestThreadLocal; import com.dotcms.business.CloseDBIfOpened; import com.dotcms.business.WrapInTransaction; +import com.dotcms.cdi.CDIUtils; import com.dotcms.concurrent.DotConcurrentFactory; import com.dotcms.concurrent.lock.IdentifierStripedLock; import com.dotcms.content.elasticsearch.business.event.ContentletArchiveEvent; @@ -12,11 +14,9 @@ import com.dotcms.content.elasticsearch.business.event.ContentletPublishEvent; import com.dotcms.content.elasticsearch.business.field.FieldHandlerStrategyFactory; import com.dotcms.content.elasticsearch.constants.ESMappingConstants; -import com.dotcms.content.elasticsearch.util.ESUtils; import com.dotcms.content.elasticsearch.util.PaginationUtil; -import com.dotcms.contenttype.business.BaseTypeToContentTypeStrategy; -import com.dotcms.contenttype.business.BaseTypeToContentTypeStrategyResolver; -import com.dotcms.contenttype.business.ContentTypeAPI; +import com.dotcms.contenttype.business.*; +import com.dotcms.contenttype.business.uniquefields.UniqueFieldValidationStrategyResolver; import com.dotcms.contenttype.exception.NotFoundInDbException; import com.dotcms.contenttype.model.field.BinaryField; import com.dotcms.contenttype.model.field.CategoryField; @@ -236,6 +236,8 @@ */ public class ESContentletAPIImpl implements ContentletAPI { + private static Lazy FEATURE_FLAG_DB_UNIQUE_FIELD_VALIDATION = Lazy.of(() -> + Config.getBooleanProperty("FEATURE_FLAG_DB_UNIQUE_FIELD_VALIDATION", false)); private static final String CAN_T_CHANGE_STATE_OF_CHECKED_OUT_CONTENT = "Can't change state of checked out content or where inode is not set. Use Search or Find then use method"; private static final String CANT_GET_LOCK_ON_CONTENT = "Only the CMS Admin or the user who locked the contentlet can lock/unlock it"; private static final String FAILED_TO_DELETE_UNARCHIVED_CONTENT = "Failed to delete unarchived content. Content must be archived first before it can be deleted."; @@ -277,6 +279,9 @@ public class ESContentletAPIImpl implements ContentletAPI { private final BaseTypeToContentTypeStrategyResolver baseTypeToContentTypeStrategyResolver = BaseTypeToContentTypeStrategyResolver.getInstance(); + + private final Lazy uniqueFieldValidationStrategyResolver; + public enum QueryType { search, suggest, moreLike, Facets } @@ -286,11 +291,21 @@ public enum QueryType { private static final Supplier ND_SUPPLIER = () -> "N/D"; private final ElasticReadOnlyCommand elasticReadOnlyCommand; + public static boolean getFeatureFlagDbUniqueFieldValidation() { + return FEATURE_FLAG_DB_UNIQUE_FIELD_VALIDATION.get(); + } + + @VisibleForTesting + public static void setFeatureFlagDbUniqueFieldValidation(final boolean newValue) { + FEATURE_FLAG_DB_UNIQUE_FIELD_VALIDATION = Lazy.of(() -> newValue); + } + /** * Default class constructor. */ @VisibleForTesting public ESContentletAPIImpl(final ElasticReadOnlyCommand readOnlyCommand) { + this.uniqueFieldValidationStrategyResolver = Lazy.of( () -> getUniqueFieldValidationStrategyResolver()); indexAPI = new ContentletIndexAPIImpl(); contentFactory = new ESContentFactoryImpl(); permissionAPI = APILocator.getPermissionAPI(); @@ -307,11 +322,20 @@ public ESContentletAPIImpl(final ElasticReadOnlyCommand readOnlyCommand) { fileMetadataAPI = APILocator.getFileMetadataAPI(); } + private static UniqueFieldValidationStrategyResolver getUniqueFieldValidationStrategyResolver() { + final Optional uniqueFieldValidationStrategyResolver = + CDIUtils.getBean(UniqueFieldValidationStrategyResolver.class); + + if (!uniqueFieldValidationStrategyResolver.isPresent()) { + throw new DotRuntimeException("Could not instance UniqueFieldValidationStrategyResolver"); + } + return uniqueFieldValidationStrategyResolver.get(); + } + public ESContentletAPIImpl() { this(ElasticReadOnlyCommand.getInstance()); } - @Override public SearchResponse esSearchRaw(String esQuery, boolean live, User user, boolean respectFrontendRoles) throws DotSecurityException, DotDataException { @@ -5516,6 +5540,11 @@ private Contentlet internalCheckin(Contentlet contentlet, contentlet = contentFactory.save(contentlet); } + if (hasUniqueField(contentType)) { + uniqueFieldValidationStrategyResolver.get().get().afterSaved(contentlet, isNewContent); + } + + contentlet.setIndexPolicy(indexPolicy); contentlet.setIndexPolicyDependencies(indexPolicyDependencies); @@ -5645,6 +5674,10 @@ private Contentlet internalCheckin(Contentlet contentlet, return contentlet; } + private static boolean hasUniqueField(ContentType contentType) { + return contentType.fields().stream().anyMatch(field -> field.unique()); + } + private boolean shouldRemoveOldHostCache(Contentlet contentlet, String oldHostId) { return contentlet.getBoolProperty(Contentlet.TO_BE_PUBLISH) && contentlet.isVanityUrl() && @@ -7632,98 +7665,16 @@ public void validateContentlet(final Contentlet contentlet, final List // validate unique if (field.isUnique()) { - final boolean isDataTypeNumber = - field.getDataType().contains(DataTypes.INTEGER.toString()) - || field.getDataType().contains(DataTypes.FLOAT.toString()); - try { - final StringBuilder buffy = new StringBuilder(UUIDGenerator.generateUuid()); - buffy.append(" +structureInode:" + contentlet.getContentTypeId()); - if (UtilMethods.isSet(contentlet.getIdentifier())) { - buffy.append(" -(identifier:" + contentlet.getIdentifier() + ")"); - } - buffy.append(" +languageId:" + contentlet.getLanguageId()); - - if (getUniquePerSiteConfig(field)) { - if (!UtilMethods.isSet(contentlet.getHost())) { - populateHost(contentlet); - } - - buffy.append(" +conHost:" + contentlet.getHost()); - } - - buffy.append(" +").append(contentlet.getContentType().variable()) - .append(StringPool.PERIOD) - .append(field.getVelocityVarName()).append(ESUtils.SHA_256) - .append(StringPool.COLON) - .append(ESUtils.sha256(contentlet.getContentType().variable() - + StringPool.PERIOD + field.getVelocityVarName(), fieldValue, - contentlet.getLanguageId())); - - final List contentlets = new ArrayList<>(); - try { - contentlets.addAll( - searchIndex(buffy.toString() + " +working:true", -1, 0, "inode", - APILocator.getUserAPI().getSystemUser(), false)); - contentlets.addAll( - searchIndex(buffy.toString() + " +live:true", -1, 0, "inode", - APILocator.getUserAPI().getSystemUser(), false)); - } catch (final Exception e) { - final String errorMsg = - "Unique field [" + field.getVelocityVarName() + "] with value '" + - fieldValue + "' could not be validated: " + e.getMessage(); - Logger.warn(this, errorMsg, e); - throw new DotContentletValidationException(errorMsg, e); - } - int size = contentlets.size(); - if (size > 0 && !hasError) { - boolean unique = true; - for (final ContentletSearch contentletSearch : contentlets) { - final Contentlet uniqueContent = contentFactory.find( - contentletSearch.getInode()); - if (null == uniqueContent) { - final String errorMsg = String.format( - "Unique field [%s] could not be validated, as " + - "unique content Inode '%s' was not found. ES Index might need to be reindexed.", - field.getVelocityVarName(), contentletSearch.getInode()); - Logger.warn(this, errorMsg); - throw new DotContentletValidationException(errorMsg); - } - final Map uniqueContentMap = uniqueContent.getMap(); - final Object obj = uniqueContentMap.get(field.getVelocityVarName()); - if ((isDataTypeNumber && Objects.equals(fieldValue, obj)) || - (!isDataTypeNumber && ((String) obj).equalsIgnoreCase( - ((String) fieldValue)))) { - unique = false; - break; - } - - } - if (!unique) { - if (UtilMethods.isSet(contentlet.getIdentifier())) { - Iterator contentletsIter = contentlets.iterator(); - while (contentletsIter.hasNext()) { - ContentletSearch cont = contentletsIter.next(); - if (!contentlet.getIdentifier() - .equalsIgnoreCase(cont.getIdentifier())) { - cve.addUniqueField(field); - hasError = true; - Logger.warn(this, - getUniqueFieldErrorMessage(field, fieldValue, - cont)); - break; - } - } - } else { - cve.addUniqueField(field); - hasError = true; - Logger.warn(this, getUniqueFieldErrorMessage(field, fieldValue, - contentlets.get(0))); - break; - } - } - } - } catch (final DotDataException | DotSecurityException e) { + try { + uniqueFieldValidationStrategyResolver.get().get().validate(contentlet, + LegacyFieldTransformer.from(field)); + } catch (UniqueFieldValueDuplicatedException e) { + cve.addUniqueField(field); + hasError = true; + Logger.warn(this, getUniqueFieldErrorMessage(field, fieldValue, + UtilMethods.isSet(e.getContentlets()) ? e.getContentlets().get(0) : "Unknown")); + } catch (DotDataException | DotSecurityException e) { Logger.warn(this, "Unable to get contentlets for Content Type: " + contentlet.getContentType().name(), e); } @@ -7799,11 +7750,11 @@ private boolean isIgnorableField(final com.dotcms.contenttype.model.field.Field } private String getUniqueFieldErrorMessage(final Field field, final Object fieldValue, - final ContentletSearch contentletSearch) { + final String contentletID) { return String.format( "Value of Field [%s] must be unique. Contents having the same value (%s): %s", - field.getVelocityVarName(), fieldValue, contentletSearch.getIdentifier()); + field.getVelocityVarName(), fieldValue, contentletID); } private boolean getUniquePerSiteConfig(final Field field) { diff --git a/dotCMS/src/main/java/com/dotcms/contenttype/business/UniqueFieldValueDuplicatedException.java b/dotCMS/src/main/java/com/dotcms/contenttype/business/UniqueFieldValueDuplicatedException.java new file mode 100644 index 000000000000..b3a1e2f70046 --- /dev/null +++ b/dotCMS/src/main/java/com/dotcms/contenttype/business/UniqueFieldValueDuplicatedException.java @@ -0,0 +1,24 @@ +package com.dotcms.contenttype.business; + +import java.util.List; + +/** + * Throw if try to insert a duplicated register in unique_fiedls table + */ +public class UniqueFieldValueDuplicatedException extends Exception{ + + private List contentletsIDS; + + public UniqueFieldValueDuplicatedException(String message) { + super(message); + } + + public UniqueFieldValueDuplicatedException(String message, List contentletsIDS) { + super(message); + this.contentletsIDS = contentletsIDS; + } + + public List getContentlets() { + return contentletsIDS; + } +} diff --git a/dotCMS/src/main/java/com/dotcms/contenttype/business/uniquefields/ESUniqueFieldValidationStrategy.java b/dotCMS/src/main/java/com/dotcms/contenttype/business/uniquefields/ESUniqueFieldValidationStrategy.java new file mode 100644 index 000000000000..bf2f32261b8f --- /dev/null +++ b/dotCMS/src/main/java/com/dotcms/contenttype/business/uniquefields/ESUniqueFieldValidationStrategy.java @@ -0,0 +1,180 @@ +package com.dotcms.contenttype.business.uniquefields; + +import com.dotcms.content.elasticsearch.util.ESUtils; +import com.dotcms.contenttype.business.UniqueFieldValueDuplicatedException; +import com.dotmarketing.portlets.contentlet.model.Contentlet; +import com.dotcms.contenttype.model.field.DataTypes; +import com.dotcms.contenttype.model.field.Field; +import com.dotcms.contenttype.model.type.ContentType; +import com.dotmarketing.business.APILocator; +import com.dotmarketing.common.model.ContentletSearch; +import com.dotmarketing.exception.DotDataException; +import com.dotmarketing.exception.DotSecurityException; +import com.dotmarketing.portlets.contentlet.business.DotContentletValidationException; +import com.dotmarketing.util.Logger; +import com.dotmarketing.util.UUIDGenerator; +import com.dotmarketing.util.UtilMethods; +import com.liferay.util.StringPool; + +import javax.enterprise.context.ApplicationScoped; +import javax.enterprise.inject.Default; +import javax.inject.Inject; +import java.util.*; +import java.util.stream.Collectors; + +import static com.dotcms.content.elasticsearch.business.ESContentletAPIImpl.UNIQUE_PER_SITE_FIELD_VARIABLE_NAME; + +/** + * {@link UniqueFieldValidationStrategy} that check the unique values using ElasticSearch queries. + * + * The query that is run looks like the follow: + * + * +structureInode:[typeInode] -identifier: [contentletId] +languageId:[contentletLang] +conHost:[hostId] [typeVariable].[uniqueFieldVariable]_sha256 = sha256(fieldValue) + * + * Where: + * - typeInode: Inode of the Content Type + * - contentletId: {@link Contentlet}'s Identifier, this filter is just add if the {@link Contentlet} is not new, + * if it is a new {@link Contentlet} then this filter is removed because the {@link Contentlet} does not have any Id after be saved. + * - contentletLang: {@link Contentlet}'s language + * - [typeVariable].[uniqueFieldVariable]_sha256 = sha256(fieldValue): For each unique field an extra attribute is saved + * in ElasticSearch it is named concatenating _sha256 to the name of the unique field, so here the filter is looking for the value. + * + * If this query return any register then it means that the value was already taken so a UniqueFieldValidationStrategy is thrown. + * + * This approach has a race condition bug because if another {@link Contentlet} is saved before that the change is mirror + * in ES then the duplicate value is going to be allowed, remember that the {@link Contentlet} sare storage in ES in an async way. + */ +@ApplicationScoped +@Default +public class ESUniqueFieldValidationStrategy implements UniqueFieldValidationStrategy { + + /** + * ES implementation for {@link UniqueFieldValidationStrategy#innerValidate(Contentlet, Field, Object, ContentType)} + * + * @param contentlet + * @param uniqueField + * @param fieldValue + * @param contentType + * @throws UniqueFieldValueDuplicatedException + * @throws DotDataException + * @throws DotSecurityException + */ + @Override + public void innerValidate(final Contentlet contentlet, final Field uniqueField, final Object fieldValue, + final ContentType contentType) + throws UniqueFieldValueDuplicatedException, DotDataException, DotSecurityException { + + final boolean isDataTypeNumber = + uniqueField.dataType().equals(DataTypes.INTEGER) + || uniqueField.dataType().equals(DataTypes.FLOAT); + + final List contentlets = getContentletFromES(contentlet, uniqueField, fieldValue); + int size = contentlets.size(); + + if (size > 0) { + boolean unique = true; + + for (final ContentletSearch contentletSearch : contentlets) { + final com.dotmarketing.portlets.contentlet.model.Contentlet uniqueContent = APILocator.getContentletAPI() + .find(contentletSearch.getInode(), APILocator.systemUser(), false); + + if (null == uniqueContent) { + final String errorMsg = String.format( + "Unique field [%s] could not be validated, as " + + "unique content Inode '%s' was not found. ES Index might need to be reindexed.", + uniqueField.variable(), contentletSearch.getInode()); + Logger.warn(this, errorMsg); + throw new DotContentletValidationException(errorMsg); + } + + final Map uniqueContentMap = uniqueContent.getMap(); + final Object obj = uniqueContentMap.get(uniqueField.variable()); + + if ((isDataTypeNumber && Objects.equals(fieldValue, obj)) || + (!isDataTypeNumber && ((String) obj).equalsIgnoreCase( + ((String) fieldValue)))) { + unique = false; + break; + } + + } + + if (!unique) { + if (UtilMethods.isSet(contentlet.getIdentifier())) { + Iterator contentletsIter = contentlets.iterator(); + while (contentletsIter.hasNext()) { + ContentletSearch cont = contentletsIter.next(); + if (!contentlet.getIdentifier() + .equalsIgnoreCase(cont.getIdentifier())) { + + final String duplicatedValueMessage = String.format("The value %s for the field %s in the Content type %s is duplicated", + fieldValue, uniqueField.variable(), contentType.variable()); + + throw new UniqueFieldValueDuplicatedException(duplicatedValueMessage, + contentlets.stream().map(ContentletSearch::getIdentifier).collect(Collectors.toList())); + } + } + } else { + final String duplicatedValueMessage = String.format("The value %s for the field %s in the Content type %s is duplicated", + fieldValue, uniqueField.variable(), contentType.variable()); + + throw new UniqueFieldValueDuplicatedException(duplicatedValueMessage, + contentlets.stream().map(ContentletSearch::getIdentifier).collect(Collectors.toList())); + } + } + } + } + + /** + * Build and execute the Lucene Query to check unique fields validation in ES. + * + * @param contentlet + * @param uniqueField + * @param fieldValue + * @return + */ + private List getContentletFromES(Contentlet contentlet, Field uniqueField, Object fieldValue) { + final StringBuilder buffy = new StringBuilder(UUIDGenerator.generateUuid()); + buffy.append(" +structureInode:" + contentlet.getContentTypeId()); + if (UtilMethods.isSet(contentlet.getIdentifier())) { + buffy.append(" -(identifier:" + contentlet.getIdentifier() + ")"); + } + + buffy.append(" +languageId:" + contentlet.getLanguageId()); + + if (getUniquePerSiteConfig(uniqueField)) { + + buffy.append(" +conHost:" + contentlet.getHost()); + } + + buffy.append(" +").append(contentlet.getContentType().variable()) + .append(StringPool.PERIOD) + .append(uniqueField.variable()).append(ESUtils.SHA_256) + .append(StringPool.COLON) + .append(ESUtils.sha256(contentlet.getContentType().variable() + + StringPool.PERIOD + uniqueField.variable(), fieldValue, + contentlet.getLanguageId())); + + final List contentlets = new ArrayList<>(); + try { + contentlets.addAll( + APILocator.getContentletAPI().searchIndex(buffy.toString() + " +working:true", -1, 0, "inode", + APILocator.getUserAPI().getSystemUser(), false)); + contentlets.addAll( + APILocator.getContentletAPI().searchIndex(buffy.toString() + " +live:true", -1, 0, "inode", + APILocator.getUserAPI().getSystemUser(), false)); + } catch (final Exception e) { + final String errorMsg = + "Unique field [" + uniqueField.variable() + "] with value '" + + fieldValue + "' could not be validated: " + e.getMessage(); + Logger.warn(this, errorMsg, e); + throw new DotContentletValidationException(errorMsg, e); + } + return contentlets; + } + + private boolean getUniquePerSiteConfig(final com.dotcms.contenttype.model.field.Field field) { + return field.fieldVariableValue(UNIQUE_PER_SITE_FIELD_VARIABLE_NAME) + .map(value -> Boolean.valueOf(value)).orElse(false); + } +} diff --git a/dotCMS/src/main/java/com/dotcms/contenttype/business/uniquefields/UniqueFieldValidationStrategy.java b/dotCMS/src/main/java/com/dotcms/contenttype/business/uniquefields/UniqueFieldValidationStrategy.java new file mode 100644 index 000000000000..5438ee97c576 --- /dev/null +++ b/dotCMS/src/main/java/com/dotcms/contenttype/business/uniquefields/UniqueFieldValidationStrategy.java @@ -0,0 +1,94 @@ +package com.dotcms.contenttype.business.uniquefields; + +import com.dotcms.contenttype.business.UniqueFieldValueDuplicatedException; +import com.dotmarketing.portlets.contentlet.model.Contentlet; +import com.dotcms.contenttype.model.field.Field; +import com.dotcms.contenttype.model.type.ContentType; +import com.dotcms.util.DotPreconditions; +import com.dotmarketing.business.APILocator; +import com.dotmarketing.exception.DotDataException; +import com.dotmarketing.exception.DotSecurityException; + +import java.util.Objects; + +/** + * Represent a Strategy to check if a value may violate unique field constraints. + */ +public interface UniqueFieldValidationStrategy { + + /** + * This method checks if a contentlet can be saved without violating unique field constraints. + * If a constraint is violated, a {@link UniqueFieldValueDuplicatedException} will be thrown. + * For content types with multiple unique fields, this method must be called for each unique field individually. + * + * This method performs the following checks: + * + * - Ensures the {@link Contentlet}, the {@link Field}, and the value of the {@link Field} in the {@link Contentlet} + * are not null. + * - Verifies that the {@link Field} is indeed a unique field. If not, an {@link IllegalArgumentException} is thrown. + * - Ensures the {@link Field} is part of the {@link Contentlet}'s {@link ContentType}. + * If not, an {@link IllegalArgumentException} is thrown. + * - Calls the {@link UniqueFieldValidationStrategy#innerValidate(Contentlet, Field, Object, ContentType)} method, + * which must be overridden by subclasses. This method is responsible for the actual unique value validation. + * + * @param contentlet that is going to be saved + * @param uniqueField Unique field to check + * @throws UniqueFieldValueDuplicatedException If the unique field contraints is violate + * @throws DotDataException If it is thrown in the process + * @throws DotSecurityException If it is thrown in the process + */ + default void validate(final Contentlet contentlet, final Field uniqueField) + throws UniqueFieldValueDuplicatedException, DotDataException, DotSecurityException { + + if (!uniqueField.unique()) { + throw new IllegalArgumentException("The Field " + uniqueField.variable() + " is not unique"); + } + + Object value = contentlet.get(uniqueField.variable()); + + Objects.requireNonNull(contentlet); + Objects.requireNonNull(uniqueField); + Objects.requireNonNull(value); + + final ContentType contentType = APILocator.getContentTypeAPI(APILocator.systemUser()) + .find(contentlet.getContentTypeId()); + + DotPreconditions.isTrue(contentType.fields().stream() + .anyMatch(contentTypeField -> uniqueField.variable().equals(contentTypeField.variable())), + "Field %s must be one of the field of the ContentType"); + + innerValidate(contentlet, uniqueField, value, contentType); + } + + /** + * Inner validation this method must be Override for each {@link UniqueFieldValidationStrategy} to implements + * the real validation approach for the specific strategy. + * + * This method must be called just by the {@link UniqueFieldValidationStrategy#validate(Contentlet, Field)} method. + * + * @param contentlet {@link Contentlet} to be saved + * @param field Field to be validated + * @param fieldValue Value to be set + * @param contentType {@link Contentlet}'s {@link ContentType} + * + * @throws UniqueFieldValueDuplicatedException If the unique field contraints is violate + * @throws DotDataException If it is thrown in the process + * @throws DotSecurityException If it is thrown in the process + */ + void innerValidate(final Contentlet contentlet, final Field field, final Object fieldValue, + ContentType contentType) + throws UniqueFieldValueDuplicatedException, DotDataException, DotSecurityException; + + /** + * This method is called after a {@link Contentlet} is saved. It allows the Strategy to perform any necessary + * actions to ensure it functions correctly the next time it's used. If the {@link Contentlet} is new when the validate + * method is called, its ID might not be set yet, so it may be necessary to assign the ID to save information + * for future use by the Strategy. + * +// * @param contentlet {@link Contentlet} saved + * @param isNew if it is true then the {@link Contentlet} is new, otherwise the {@link Contentlet} was updated + */ + default void afterSaved(final Contentlet contentlet, final boolean isNew) throws DotDataException, DotSecurityException, UniqueFieldValueDuplicatedException { + + } +} diff --git a/dotCMS/src/main/java/com/dotcms/contenttype/business/uniquefields/UniqueFieldValidationStrategyResolver.java b/dotCMS/src/main/java/com/dotcms/contenttype/business/uniquefields/UniqueFieldValidationStrategyResolver.java new file mode 100644 index 000000000000..2db8a7bc7e55 --- /dev/null +++ b/dotCMS/src/main/java/com/dotcms/contenttype/business/uniquefields/UniqueFieldValidationStrategyResolver.java @@ -0,0 +1,42 @@ +package com.dotcms.contenttype.business.uniquefields; + +import com.dotcms.cdi.CDIUtils; +import com.dotcms.content.elasticsearch.business.ESContentletAPIImpl; +import com.dotcms.contenttype.business.uniquefields.extratable.DBUniqueFieldValidationStrategy; +import com.dotmarketing.exception.DotRuntimeException; +import com.google.common.annotations.VisibleForTesting; + +import javax.enterprise.context.ApplicationScoped; +import javax.inject.Inject; +import java.util.Optional; + +/** + * Utility class responsible for returning the appropriate {@link UniqueFieldValidationStrategy} + * based on the configuration setting of ENABLED_UNIQUE_FIELDS_DATABASE_VALIDATION. If this setting is true, + * an {@link DBUniqueFieldValidationStrategy} is returned; otherwise, + * an {@link ESUniqueFieldValidationStrategy} is used. + * + */ +@ApplicationScoped +public class UniqueFieldValidationStrategyResolver { + + @Inject + private ESUniqueFieldValidationStrategy esUniqueFieldValidationStrategy; + @Inject + private DBUniqueFieldValidationStrategy dbUniqueFieldValidationStrategy; + + public UniqueFieldValidationStrategyResolver(){} + + @VisibleForTesting + public UniqueFieldValidationStrategyResolver(final ESUniqueFieldValidationStrategy esUniqueFieldValidationStrategy, + final DBUniqueFieldValidationStrategy dbUniqueFieldValidationStrategy){ + this.esUniqueFieldValidationStrategy = esUniqueFieldValidationStrategy; + this.dbUniqueFieldValidationStrategy = dbUniqueFieldValidationStrategy; + + } + + public UniqueFieldValidationStrategy get() { + return ESContentletAPIImpl.getFeatureFlagDbUniqueFieldValidation() ? + dbUniqueFieldValidationStrategy : esUniqueFieldValidationStrategy; + } +} diff --git a/dotCMS/src/main/java/com/dotcms/contenttype/business/uniquefields/extratable/DBUniqueFieldValidationStrategy.java b/dotCMS/src/main/java/com/dotcms/contenttype/business/uniquefields/extratable/DBUniqueFieldValidationStrategy.java new file mode 100644 index 000000000000..c8128859e5e0 --- /dev/null +++ b/dotCMS/src/main/java/com/dotcms/contenttype/business/uniquefields/extratable/DBUniqueFieldValidationStrategy.java @@ -0,0 +1,191 @@ +package com.dotcms.contenttype.business.uniquefields.extratable; + +import com.dotcms.business.CloseDBIfOpened; +import com.dotcms.business.WrapInTransaction; +import com.dotcms.content.elasticsearch.business.ESContentletAPIImpl; +import com.dotcms.contenttype.business.*; +import com.dotcms.contenttype.business.uniquefields.UniqueFieldValidationStrategy; +import com.dotcms.contenttype.model.type.ContentType; +import com.dotcms.util.CollectionsUtils; +import com.dotcms.util.JsonUtil; +import com.dotmarketing.beans.Host; +import com.dotmarketing.business.APILocator; +import com.dotmarketing.exception.DotDataException; +import com.dotmarketing.exception.DotSecurityException; +import com.dotmarketing.portlets.contentlet.model.Contentlet; +import com.dotmarketing.portlets.languagesmanager.model.Language; +import com.dotcms.contenttype.model.field.Field; +import com.dotmarketing.util.Logger; +import com.dotmarketing.util.UtilMethods; +import com.google.common.annotations.VisibleForTesting; +import com.liferay.portal.model.User; +import org.postgresql.util.PGobject; + +import javax.enterprise.context.ApplicationScoped; +import javax.enterprise.inject.Default; +import javax.inject.Inject; + +import static com.dotcms.util.CollectionsUtils.list; + +import java.io.IOException; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.stream.Collectors; + + +/** + * {@link UniqueFieldValidationStrategy} that check the unique values using a SQL Query and a Extra table. + * This is the same extra table created here {@link com.dotmarketing.startup.runonce.Task241007CreateUniqueFieldsTable} + */ +@ApplicationScoped +public class DBUniqueFieldValidationStrategy implements UniqueFieldValidationStrategy { + + @Inject + private UniqueFieldDataBaseUtil uniqueFieldDataBaseUtil; + + public DBUniqueFieldValidationStrategy(){ + } + + @VisibleForTesting + public DBUniqueFieldValidationStrategy(final UniqueFieldDataBaseUtil uniqueFieldDataBaseUtil){ + this.uniqueFieldDataBaseUtil = uniqueFieldDataBaseUtil; + } + + /** + * + * @param contentlet + * @param field + * @param fieldValue + * @param contentType + * + * @throws UniqueFieldValueDuplicatedException + * @throws DotDataException + * @throws DotSecurityException + */ + @Override + @WrapInTransaction + @CloseDBIfOpened + public void innerValidate(final Contentlet contentlet, final Field field, final Object fieldValue, + final ContentType contentType) throws UniqueFieldValueDuplicatedException, DotDataException, DotSecurityException { + + if (UtilMethods.isSet(contentlet.getIdentifier())) { + cleanUniqueFieldsUp(contentlet, field); + } + + final User systemUser = APILocator.systemUser(); + final Host host = APILocator.getHostAPI().find(contentlet.getHost(), systemUser, false); + final Language language = APILocator.getLanguageAPI().getLanguage(contentlet.getLanguageId()); + + UniqueFieldCriteria uniqueFieldCriteria = new UniqueFieldCriteria.Builder() + .setSite(host) + .setLanguage(language) + .setField(field) + .setContentType(contentType) + .setValue(fieldValue) + .setVariantName(contentlet.getVariantId()) + .build(); + + checkUnique(uniqueFieldCriteria, contentlet.getIdentifier()); + } + + private void cleanUniqueFieldsUp(final Contentlet contentlet, final Field field) throws DotDataException { + Optional> uniqueFieldOptional = uniqueFieldDataBaseUtil.get(contentlet); + + try { + if (uniqueFieldOptional.isPresent()) { + final Map uniqueFields = uniqueFieldOptional.get(); + + final String hash = uniqueFields.get("unique_key_val").toString(); + final PGobject supportingValues = (PGobject) uniqueFields.get("supporting_values"); + final Map supportingValuesMap = JsonUtil.getJsonFromString(supportingValues.getValue()); + final List contentletsId = (List) supportingValuesMap.get("contentletsId"); + + if (contentletsId.size() == 1) { + uniqueFieldDataBaseUtil.delete(hash, field.variable()); + } else { + contentletsId.remove(contentlet.getIdentifier()); + uniqueFieldDataBaseUtil.updateContentLists(hash, contentletsId); + } + } + } catch (IOException e){ + throw new DotDataException(e); + } + } + + //@Override + public void afterSaved(final Contentlet contentlet, final boolean isNew) throws DotDataException, DotSecurityException { + if (isNew) { + final ContentType contentType = APILocator.getContentTypeAPI(APILocator.systemUser()) + .find(contentlet.getContentTypeId()); + + final User systemUser = APILocator.systemUser(); + final Host host = APILocator.getHostAPI().find(contentlet.getHost(), systemUser, false); + final Language language = APILocator.getLanguageAPI().getLanguage(contentlet.getLanguageId()); + + final List uniqueFields = contentType.fields().stream() + .filter(Field::unique) + .collect(Collectors.toList()); + + if (uniqueFields.isEmpty()) { + throw new IllegalArgumentException("The ContentType must contains at least one Unique Field"); + } + + for (final Field uniqueField : uniqueFields) { + final Object fieldValue = contentlet.get(uniqueField.variable()); + + UniqueFieldCriteria uniqueFieldCriteria = new UniqueFieldCriteria.Builder() + .setSite(host) + .setLanguage(language) + .setField(uniqueField) + .setContentType(contentType) + .setValue(fieldValue) + .build(); + + uniqueFieldDataBaseUtil.updateContentList(uniqueFieldCriteria.hash(), contentlet.getIdentifier()); + } + } + } + + /** + * Insert a new unique field value, if the value is duplicated then a {@link java.sql.SQLException} is thrown. + * + * @param uniqueFieldCriteria + * @param contentletId + * + * @throws UniqueFieldValueDuplicatedException when the Value is duplicated + * @throws DotDataException when a DotDataException is throws + */ + private void checkUnique(UniqueFieldCriteria uniqueFieldCriteria, String contentletId) throws UniqueFieldValueDuplicatedException { + final boolean uniqueForSite = uniqueFieldCriteria.field().fieldVariableValue(ESContentletAPIImpl.UNIQUE_PER_SITE_FIELD_VARIABLE_NAME) + .map(Boolean::valueOf).orElse(false); + + final Map supportingValues = new HashMap<>(uniqueFieldCriteria.toMap()); + supportingValues.put("contentletsId", CollectionsUtils.list(contentletId)); + supportingValues.put("uniquePerSite", uniqueForSite); + + try { + Logger.debug(DBUniqueFieldValidationStrategy.class, "Including value in the unique_fields table"); + uniqueFieldDataBaseUtil.insert(uniqueFieldCriteria.hash(), supportingValues); + } catch (DotDataException e) { + + if (isDuplicatedKeyError(e)) { + final String duplicatedValueMessage = String.format("The value %s for the field %s in the Content type %s is duplicated", + uniqueFieldCriteria.value(), uniqueFieldCriteria.field().variable(), + uniqueFieldCriteria.contentType().variable()); + + Logger.error(DBUniqueFieldValidationStrategy.class, duplicatedValueMessage); + throw new UniqueFieldValueDuplicatedException(duplicatedValueMessage); + } + } + } + + + private static boolean isDuplicatedKeyError(final Exception exception) { + final String originalMessage = exception.getMessage(); + + return originalMessage != null && originalMessage.startsWith( + "ERROR: duplicate key value violates unique constraint \"unique_fields_pkey\""); + } +} diff --git a/dotCMS/src/main/java/com/dotcms/contenttype/business/uniquefields/extratable/UniqueFieldCriteria.java b/dotCMS/src/main/java/com/dotcms/contenttype/business/uniquefields/extratable/UniqueFieldCriteria.java new file mode 100644 index 000000000000..a98261653da3 --- /dev/null +++ b/dotCMS/src/main/java/com/dotcms/contenttype/business/uniquefields/extratable/UniqueFieldCriteria.java @@ -0,0 +1,166 @@ +package com.dotcms.contenttype.business.uniquefields.extratable; + +import com.dotcms.content.elasticsearch.business.ESContentletAPIImpl; +import com.dotcms.contenttype.model.field.Field; +import com.dotcms.contenttype.model.type.ContentType; +import com.dotcms.variant.model.Variant; +import com.dotmarketing.beans.Host; +import com.dotmarketing.business.APILocator; +import com.dotmarketing.exception.DotDataException; +import com.dotmarketing.exception.DotRuntimeException; +import com.dotmarketing.portlets.languagesmanager.model.Language; +import com.dotmarketing.util.StringUtils; +import com.liferay.util.StringPool; + +import java.io.Serializable; +import java.util.HashMap; +import java.util.Map; +import java.util.Objects; + +/** + * Represent the criteria used to determine if a value is unique or not + */ +class UniqueFieldCriteria { + private final ContentType contentType; + private final Field field; + private final Object value; + private final Language language; + private final Host site; + private String variantName; + + public UniqueFieldCriteria(final Builder builder) { + this.contentType = builder.contentType; + this.field = builder.field; + this.value = builder.value; + this.language = builder.language; + this.site = builder.site; + this.variantName = builder.variantName; + } + + + /** + * Return a Map with the values in this Unique Field Criteria + * @return + */ + public Map toMap(){ + final Map map = new HashMap<>(Map.of( + "contentTypeID", Objects.requireNonNull(contentType.id()), + "fieldVariableName", Objects.requireNonNull(field.variable()), + "fieldValue", value.toString(), + "languageId", language.getId(), + "uniquePerSite", isUniqueForSite(contentType.id(), field.variable()), + "variant", variantName + )); + + if (site != null) { + map.put("hostId", site.getIdentifier()); + } + + return map; + } + + /** + * return true if the uniquePerSite Field Variable is set to true. + * + * @param contentTypeId + * @param fieldVariableName + * @return + */ + private static boolean isUniqueForSite(String contentTypeId, String fieldVariableName) { + try { + final Field uniqueField = APILocator.getContentTypeFieldAPI().byContentTypeIdAndVar(contentTypeId, fieldVariableName); + + return uniqueField.fieldVariableValue(ESContentletAPIImpl.UNIQUE_PER_SITE_FIELD_VARIABLE_NAME) + .map(Boolean::valueOf).orElse(false); + } catch (DotDataException e) { + throw new DotRuntimeException( + String.format("Impossible to get FieldVariable from Field: %s, Content Type: %s", + fieldVariableName, contentTypeId), e); + } + } + + /** + * Return a hash calculated as follow: + * + * - If the uniquePerSite Field Variable is set to true then concat the: + * Content Type' id + Field Variable Name + Language's Id + Field Value + * + * - If the uniquePerSite Field Variable is set to false then concat the: + * Content Type' id + Field Variable Name + Language's Id + Field Value + Site's id + * + * @return + */ + public String hash(){ + return StringUtils.hashText(contentType.id() + field.variable() + language.getId() + value + + ((isUniqueForSite(contentType.id(), field.variable())) ? site.getIdentifier() : StringPool.BLANK)); + } + + public Field field() { + return field; + } + + public Object value() { + return value; + } + + public ContentType contentType() { + return contentType; + } + + public Language language() { + return language; + } + + + public static class Builder { + private ContentType contentType; + private Field field; + private Object value; + private Language language; + private Host site; + private String variantName; + + public Builder setVariantName(final String variantName) { + this.variantName = variantName; + return this; + } + + public Builder setContentType(final ContentType contentType) { + this.contentType = contentType; + return this; + } + + public Builder setField(final Field field) { + this.field = field; + return this; + } + + public Builder setValue(final Object value) { + this.value = value; + return this; + } + + public Builder setLanguage(final Language language) { + this.language = language; + return this; + } + + public Builder setSite(final Host site) { + this.site = site; + return this; + } + + public UniqueFieldCriteria build(){ + Objects.requireNonNull(contentType); + Objects.requireNonNull(field); + Objects.requireNonNull(value); + Objects.requireNonNull(language); + + if (isUniqueForSite(contentType.id(), field.variable())) { + Objects.requireNonNull(site); + } + + return new UniqueFieldCriteria(this); + } + } +} diff --git a/dotCMS/src/main/java/com/dotcms/contenttype/business/uniquefields/extratable/UniqueFieldDataBaseUtil.java b/dotCMS/src/main/java/com/dotcms/contenttype/business/uniquefields/extratable/UniqueFieldDataBaseUtil.java new file mode 100644 index 000000000000..098c70f4c5ff --- /dev/null +++ b/dotCMS/src/main/java/com/dotcms/contenttype/business/uniquefields/extratable/UniqueFieldDataBaseUtil.java @@ -0,0 +1,74 @@ +package com.dotcms.contenttype.business.uniquefields.extratable; + +import com.dotmarketing.common.db.DotConnect; +import com.dotmarketing.exception.DotDataException; +import com.dotmarketing.portlets.contentlet.model.Contentlet; + +import javax.enterprise.context.ApplicationScoped; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +import static com.dotcms.util.CollectionsUtils.list; + +/** + * Util class to handle QL statement related with the unique_fiedls table + */ +@ApplicationScoped +public class UniqueFieldDataBaseUtil { + + private final static String INSERT_SQL = "INSERT INTO unique_fields (unique_key_val, supporting_values) VALUES (?, ?)"; + private final static String UPDATE_CONTENT_LIST ="UPDATE unique_fields " + + "SET supporting_values = jsonb_set(supporting_values, '{contentletsId}', ?::jsonb) " + + "WHERE unique_key_val = ?"; + + private final static String GET_UNIQUE_FIELDS_BY_CONTENTLET = "SELECT * FROM unique_fields " + + "WHERE supporting_values->'contentletsId' @> ?::jsonb AND supporting_values->>'variant' = ?"; + + private final String DELETE_UNIQUE_FIELDS = "DELETE FROM unique_fields WHERE unique_key_val = ? " + + "AND supporting_values->>'fieldVariableName' = ?"; + + /** + * Insert a new register into the unique_fields table, if already exists another register with the same + * 'unique_key_val' then a {@link java.sql.SQLException} is thrown. + * + * @param key + * @param supportingValues + */ + public void insert(final String key, final Map supportingValues) throws DotDataException { + new DotConnect().setSQL(INSERT_SQL).addParam(key).addJSONParam(supportingValues).loadObjectResults(); + } + + /** + * Update the contentList attribute in the supportingValues field of the unique_fields table. + * + * @param hash + * @param contentletId + */ + public void updateContentList(final String hash, final String contentletId) throws DotDataException { + updateContentLists(hash, list(contentletId)); + } + + public void updateContentLists(final String hash, final List contentletIds) throws DotDataException { + new DotConnect().setSQL(UPDATE_CONTENT_LIST) + .addJSONParam(contentletIds) + .addParam(hash) + .loadObjectResults(); + } + + public Optional> get(final Contentlet contentlet) throws DotDataException { + final List> results = new DotConnect().setSQL(GET_UNIQUE_FIELDS_BY_CONTENTLET) + .addParam("\"" + contentlet.getIdentifier() + "\"") + .addParam(contentlet.getVariantId()) + .loadObjectResults(); + + return results.isEmpty() ? Optional.empty() : Optional.of(results.get(0)); + } + + public void delete(final String hash, String fiedVariable) throws DotDataException { + new DotConnect().setSQL(DELETE_UNIQUE_FIELDS) + .addParam(hash) + .addParam(fiedVariable) + .loadObjectResults(); + } +} diff --git a/dotcms-integration/src/test/java/com/dotcms/MainSuite2b.java b/dotcms-integration/src/test/java/com/dotcms/MainSuite2b.java index 7f9cf6bc5622..4b716d43c119 100644 --- a/dotcms-integration/src/test/java/com/dotcms/MainSuite2b.java +++ b/dotcms-integration/src/test/java/com/dotcms/MainSuite2b.java @@ -33,9 +33,9 @@ import com.dotcms.content.business.json.LegacyJSONObjectRenderTest; import com.dotcms.content.elasticsearch.business.ESIndexAPITest; import com.dotcms.content.model.hydration.MetadataDelegateTest; -import com.dotcms.contenttype.business.ContentTypeDestroyAPIImplTest; -import com.dotcms.contenttype.business.ContentTypeInitializerTest; -import com.dotcms.contenttype.business.StoryBlockAPITest; +import com.dotcms.contenttype.business.*; +import com.dotcms.contenttype.business.uniquefields.extratable.DBUniqueFieldValidationStrategyTest; +import com.dotcms.contenttype.business.uniquefields.extratable.UniqueFieldDataBaseUtilTest; import com.dotcms.csspreproc.CSSCacheTest; import com.dotcms.csspreproc.CSSPreProcessServletTest; import com.dotcms.dotpubsub.RedisPubSubImplTest; @@ -399,6 +399,10 @@ LegacyJSONObjectRenderTest.class, Task241013RemoveFullPathLcColumnFromIdentifierTest.class, Task241009CreatePostgresJobQueueTablesTest.class, + + UniqueFieldDataBaseUtilTest.class, + DBUniqueFieldValidationStrategyTest.class, + Task241013RemoveFullPathLcColumnFromIdentifierTest.class, Task241013RemoveFullPathLcColumnFromIdentifierTest.class, Task241015ReplaceLanguagesWithLocalesPortletTest.class, Task241016AddCustomLanguageVariablesPortletToLayoutTest.class, diff --git a/dotcms-integration/src/test/java/com/dotcms/content/elasticsearch/business/ESContentletAPIImplTest.java b/dotcms-integration/src/test/java/com/dotcms/content/elasticsearch/business/ESContentletAPIImplTest.java index 15e215ab21ee..837d61a86980 100644 --- a/dotcms-integration/src/test/java/com/dotcms/content/elasticsearch/business/ESContentletAPIImplTest.java +++ b/dotcms-integration/src/test/java/com/dotcms/content/elasticsearch/business/ESContentletAPIImplTest.java @@ -3,17 +3,8 @@ import com.dotcms.IntegrationTestBase; import com.dotcms.business.WrapInTransaction; import com.dotcms.content.elasticsearch.util.RestHighLevelClientProvider; -import com.dotcms.contenttype.business.ContentTypeAPI; -import com.dotcms.contenttype.business.CopyContentTypeBean; -import com.dotcms.contenttype.business.FieldAPI; -import com.dotcms.contenttype.model.field.BinaryField; -import com.dotcms.contenttype.model.field.DataTypes; -import com.dotcms.contenttype.model.field.DateTimeField; -import com.dotcms.contenttype.model.field.Field; -import com.dotcms.contenttype.model.field.FieldBuilder; -import com.dotcms.contenttype.model.field.HostFolderField; -import com.dotcms.contenttype.model.field.RelationshipField; -import com.dotcms.contenttype.model.field.TextField; +import com.dotcms.contenttype.business.*; +import com.dotcms.contenttype.model.field.*; import com.dotcms.contenttype.model.type.ContentType; import com.dotcms.contenttype.model.type.ContentTypeBuilder; import com.dotcms.contenttype.model.type.SimpleContentType; @@ -42,6 +33,7 @@ import com.dotcms.test.util.FileTestUtil; import com.dotcms.util.CollectionsUtils; import com.dotcms.util.IntegrationTestInitService; +import com.dotcms.util.JsonUtil; import com.dotcms.vanityurl.filters.VanityURLFilter; import com.dotcms.vanityurl.model.DefaultVanityUrl; import com.dotcms.vanityurl.model.VanityUrl; @@ -83,6 +75,7 @@ import com.dotmarketing.portlets.structure.model.Structure; import com.dotmarketing.portlets.templates.model.Template; import com.dotmarketing.util.Logger; +import com.dotmarketing.util.StringUtils; import com.dotmarketing.util.UtilMethods; import com.dotmarketing.util.WebKeys.Relationship.RELATIONSHIP_CARDINALITY; import com.fasterxml.jackson.core.JsonProcessingException; @@ -92,6 +85,9 @@ import com.liferay.util.FileUtil; import com.liferay.util.StringPool; import com.rainerhahnekamp.sneakythrow.Sneaky; +import com.tngtech.java.junit.dataprovider.DataProvider; +import com.tngtech.java.junit.dataprovider.DataProviderRunner; +import com.tngtech.java.junit.dataprovider.UseDataProvider; import org.apache.http.HttpStatus; import org.elasticsearch.action.admin.cluster.settings.ClusterUpdateSettingsRequest; import org.elasticsearch.action.support.master.AcknowledgedResponse; @@ -101,7 +97,9 @@ import org.junit.Assert; import org.junit.BeforeClass; import org.junit.Test; +import org.junit.runner.RunWith; import org.mockito.Mockito; +import org.postgresql.util.PGobject; import javax.servlet.FilterChain; import javax.servlet.ServletException; @@ -111,16 +109,9 @@ import javax.servlet.http.HttpServletResponse; import java.io.File; import java.io.IOException; +import java.io.Serializable; import java.math.BigDecimal; -import java.util.ArrayList; -import java.util.Arrays; -import java.util.Calendar; -import java.util.Date; -import java.util.HashMap; -import java.util.List; -import java.util.Map; -import java.util.Optional; -import java.util.Set; +import java.util.*; import java.util.stream.Collectors; import static com.dotcms.content.elasticsearch.business.ESContentletAPIImpl.UNIQUE_PER_SITE_FIELD_VARIABLE_NAME; @@ -144,6 +135,7 @@ * * @author nollymar */ +@RunWith(DataProviderRunner.class) public class ESContentletAPIImplTest extends IntegrationTestBase { private static ContentTypeAPI contentTypeAPI; @@ -167,6 +159,21 @@ public static void prepare () throws Exception { relationshipAPI = APILocator.getRelationshipAPI(); contentletAPI = APILocator.getContentletAPI(); fieldAPI = APILocator.getContentTypeFieldAPI(); + + //TODO: Remove this when the whole change is done + try { + new DotConnect().setSQL("CREATE TABLE IF NOT EXISTS unique_fields (" + + "unique_key_val VARCHAR(64) PRIMARY KEY," + + "supporting_values JSONB" + + " )").loadObjectResults(); + } catch (DotDataException e) { + throw new RuntimeException(e); + } + } + + @DataProvider + public static Object[] enabledUniqueFieldDatabaseValidation() { + return new Boolean[] {true, false}; } @Test @@ -1079,45 +1086,100 @@ public void selfRelatedContents() throws DotDataException { * @throws DotSecurityException */ @Test - public void savingFieldWithUniqueFieldInTheSameHost() throws DotDataException, DotSecurityException { + @UseDataProvider("enabledUniqueFieldDatabaseValidation") + public void savingFieldWithUniqueFieldInTheSameHost(final Boolean enabledDataBaseValidation) + throws DotDataException, DotSecurityException { - final ContentType contentType = new ContentTypeDataGen() - .nextPersisted(); + final boolean oldEnabledDataBaseValidation = ESContentletAPIImpl.getFeatureFlagDbUniqueFieldValidation(); - final Field uniqueTextField = new FieldDataGen() - .contentTypeId(contentType.id()) - .unique(true) - .type(TextField.class) - .nextPersisted(); + try { + ESContentletAPIImpl.setFeatureFlagDbUniqueFieldValidation(enabledDataBaseValidation); + final ContentType contentType = new ContentTypeDataGen() + .nextPersisted(); - new FieldVariableDataGen() - .key(UNIQUE_PER_SITE_FIELD_VARIABLE_NAME) - .value("true") - .field(uniqueTextField) - .nextPersisted(); + final Field uniqueTextField = new FieldDataGen() + .contentTypeId(contentType.id()) + .unique(true) + .type(TextField.class) + .nextPersisted(); - final Host host = new SiteDataGen().nextPersisted(); + new FieldVariableDataGen() + .key(UNIQUE_PER_SITE_FIELD_VARIABLE_NAME) + .value("true") + .field(uniqueTextField) + .nextPersisted(); - final Contentlet contentlet_1 = new ContentletDataGen(contentType) - .host(host) - .setProperty(uniqueTextField.variable(), "unique-value") - .next(); + final Host host = new SiteDataGen().nextPersisted(); - final Contentlet contentlet_2 = new ContentletDataGen(contentType) - .host(host) - .setProperty(uniqueTextField.variable(), "unique-value") - .next(); + final Contentlet contentlet_1 = new ContentletDataGen(contentType) + .host(host) + .setProperty(uniqueTextField.variable(), "unique-value") + .next(); - APILocator.getContentletAPI().checkin(contentlet_1, APILocator.systemUser(), false); + final Contentlet contentlet_2 = new ContentletDataGen(contentType) + .host(host) + .setProperty(uniqueTextField.variable(), "unique-value") + .next(); - try { - APILocator.getContentletAPI().checkin(contentlet_2, APILocator.systemUser(), false); - throw new AssertionError("DotRuntimeException Expected"); - }catch (final DotRuntimeException e) { - final String expectedMessage = String.format("Contentlet with ID 'Unknown/New' [''] has invalid/missing field(s)." - + " - Fields: [UNIQUE]: %s (%s)", uniqueTextField.name(), uniqueTextField.variable()); + APILocator.getContentletAPI().checkin(contentlet_1, APILocator.systemUser(), false); - assertEquals(expectedMessage, e.getMessage()); + try { + APILocator.getContentletAPI().checkin(contentlet_2, APILocator.systemUser(), false); + throw new AssertionError("DotRuntimeException Expected"); + } catch (final DotRuntimeException e) { + final String expectedMessage = String.format("Contentlet with ID 'Unknown/New' [''] has invalid/missing field(s)." + + " - Fields: [UNIQUE]: %s (%s)", uniqueTextField.name(), uniqueTextField.variable()); + + assertEquals(expectedMessage, e.getMessage()); + } + + if (enabledDataBaseValidation) { + checkUniqueFieldsTable(true, contentType, uniqueTextField, contentlet_1); + } + } finally { + ESContentletAPIImpl.setFeatureFlagDbUniqueFieldValidation(oldEnabledDataBaseValidation); + } + } + + private static void checkUniqueFieldsTable(final boolean uniquePerSite, final ContentType contentType, + final Field uniqueField, Contentlet... contentlets) + throws DotDataException { + List> results = new DotConnect().setSQL("SELECT * FROM unique_fields WHERE supporting_values->>'contentTypeID' = ?") + .addParam(contentType.id()) + .loadObjectResults(); + + assertEquals(contentlets.length, results.size()); + + for (Map result : results) { + try { + final Map supportingValues = JsonUtil.getJsonFromString(result.get("supporting_values").toString()); + final List contentletsId = (List) supportingValues.get("contentletsId"); + final String variant = supportingValues.get("variant").toString(); + + assertEquals(1, contentletsId.size()); + + assertEquals(uniqueField.variable(), supportingValues.get("fieldVariableName")); + assertEquals(uniquePerSite, supportingValues.get("uniquePerSite")); + + Contentlet contentletFound = null; + + for (Contentlet contentlet : contentlets) { + if (contentletsId.get(0).equals(contentlet.getIdentifier()) && variant.equals(contentlet.getVariantId())) { + contentletFound = contentlet; + break; + } + } + + if (contentletFound == null) { + throw new AssertionError("Contentley does not expected"); + } + + assertEquals(contentletFound.get(uniqueField.variable()), supportingValues.get("fieldValue")); + assertEquals(contentletFound.getLanguageId(), Long.parseLong(supportingValues.get("languageId").toString())); + assertEquals(contentletFound.getHost(), supportingValues.get("hostId")); + } catch (IOException e) { + throw new RuntimeException(e); + } } } @@ -1135,45 +1197,58 @@ public void savingFieldWithUniqueFieldInTheSameHost() throws DotDataException, D * @throws DotSecurityException */ @Test - public void savingFieldWithUniqueFieldInTheSameHostUniquePerSiteToFalse() throws DotDataException, DotSecurityException { + @UseDataProvider("enabledUniqueFieldDatabaseValidation") + public void savingFieldWithUniqueFieldInTheSameHostUniquePerSiteToFalse(final Boolean enabledDataBaseValidation) + throws DotDataException, DotSecurityException { - final ContentType contentType = new ContentTypeDataGen() - .nextPersisted(); + final boolean oldEnabledDataBaseValidation = ESContentletAPIImpl.getFeatureFlagDbUniqueFieldValidation(); - final Field uniqueTextField = new FieldDataGen() - .contentTypeId(contentType.id()) - .unique(true) - .type(TextField.class) - .nextPersisted(); + try { + ESContentletAPIImpl.setFeatureFlagDbUniqueFieldValidation(enabledDataBaseValidation); + final ContentType contentType = new ContentTypeDataGen() + .nextPersisted(); - new FieldVariableDataGen() - .key(UNIQUE_PER_SITE_FIELD_VARIABLE_NAME) - .value("true") - .field(uniqueTextField) - .nextPersisted(); + final Field uniqueTextField = new FieldDataGen() + .contentTypeId(contentType.id()) + .unique(true) + .type(TextField.class) + .nextPersisted(); - final Host host = new SiteDataGen().nextPersisted(); + new FieldVariableDataGen() + .key(UNIQUE_PER_SITE_FIELD_VARIABLE_NAME) + .value("true") + .field(uniqueTextField) + .nextPersisted(); - final Contentlet contentlet_1 = new ContentletDataGen(contentType) - .host(host) - .setProperty(uniqueTextField.variable(), "unique-value") - .next(); + final Host host = new SiteDataGen().nextPersisted(); - final Contentlet contentlet_2 = new ContentletDataGen(contentType) - .host(host) - .setProperty(uniqueTextField.variable(), "unique-value") - .next(); + final Contentlet contentlet_1 = new ContentletDataGen(contentType) + .host(host) + .setProperty(uniqueTextField.variable(), "unique-value") + .next(); - APILocator.getContentletAPI().checkin(contentlet_1, APILocator.systemUser(), false); + final Contentlet contentlet_2 = new ContentletDataGen(contentType) + .host(host) + .setProperty(uniqueTextField.variable(), "unique-value") + .next(); - try { - APILocator.getContentletAPI().checkin(contentlet_2, APILocator.systemUser(), false); - throw new AssertionError("DotRuntimeException Expected"); - } catch (final DotRuntimeException e) { - final String expectedMessage = String.format("Contentlet with ID 'Unknown/New' [''] has invalid/missing field(s)." - + " - Fields: [UNIQUE]: %s (%s)", uniqueTextField.name(), uniqueTextField.variable()); + APILocator.getContentletAPI().checkin(contentlet_1, APILocator.systemUser(), false); - assertEquals(expectedMessage, e.getMessage()); + try { + APILocator.getContentletAPI().checkin(contentlet_2, APILocator.systemUser(), false); + throw new AssertionError("DotRuntimeException Expected"); + } catch (final DotRuntimeException e) { + final String expectedMessage = String.format("Contentlet with ID 'Unknown/New' [''] has invalid/missing field(s)." + + " - Fields: [UNIQUE]: %s (%s)", uniqueTextField.name(), uniqueTextField.variable()); + + assertEquals(expectedMessage, e.getMessage()); + } + + if (enabledDataBaseValidation) { + checkUniqueFieldsTable(true, contentType, uniqueTextField, contentlet_1); + } + } finally { + ESContentletAPIImpl.setFeatureFlagDbUniqueFieldValidation(oldEnabledDataBaseValidation); } } @@ -1191,48 +1266,63 @@ public void savingFieldWithUniqueFieldInTheSameHostUniquePerSiteToFalse() throws * @throws DotSecurityException */ @Test - public void savingFieldWithUniqueFieldInDifferentHost() throws DotDataException, DotSecurityException { - final ContentType contentType = new ContentTypeDataGen() - .nextPersisted(); + @UseDataProvider("enabledUniqueFieldDatabaseValidation") + public void savingFieldWithUniqueFieldInDifferentHost(final Boolean enabledDataBaseValidation) + throws DotDataException, DotSecurityException { - final Field uniqueTextField = new FieldDataGen() - .contentTypeId(contentType.id()) - .unique(true) - .type(TextField.class) - .nextPersisted(); - new FieldVariableDataGen() - .key(UNIQUE_PER_SITE_FIELD_VARIABLE_NAME) - .value("true") - .field(uniqueTextField) - .nextPersisted(); + final boolean oldEnabledDataBaseValidation = ESContentletAPIImpl.getFeatureFlagDbUniqueFieldValidation(); - final Host host1 = new SiteDataGen().nextPersisted(); - final Host host2 = new SiteDataGen().nextPersisted(); + try { + ESContentletAPIImpl.setFeatureFlagDbUniqueFieldValidation(enabledDataBaseValidation); - final Contentlet contentlet_1 = new ContentletDataGen(contentType) - .host(host1) - .setProperty(uniqueTextField.variable(), "unique-value") - .next(); + final ContentType contentType = new ContentTypeDataGen() + .nextPersisted(); - final Contentlet contentlet_2 = new ContentletDataGen(contentType) - .host(host2) - .setProperty(uniqueTextField.variable(), "unique-value") - .next(); + final Field uniqueTextField = new FieldDataGen() + .contentTypeId(contentType.id()) + .unique(true) + .type(TextField.class) + .nextPersisted(); + + new FieldVariableDataGen() + .key(UNIQUE_PER_SITE_FIELD_VARIABLE_NAME) + .value("true") + .field(uniqueTextField) + .nextPersisted(); - APILocator.getContentletAPI().checkin(contentlet_1, APILocator.systemUser(), false); - APILocator.getContentletAPI().checkin(contentlet_2, APILocator.systemUser(), false); + final Host host1 = new SiteDataGen().nextPersisted(); + final Host host2 = new SiteDataGen().nextPersisted(); - final Optional contentlet1FromDB = APILocator.getContentletAPI() - .findInDb(contentlet_1.getInode()); + final Contentlet contentlet_1 = new ContentletDataGen(contentType) + .host(host1) + .setProperty(uniqueTextField.variable(), "unique-value") + .next(); + + final Contentlet contentlet_2 = new ContentletDataGen(contentType) + .host(host2) + .setProperty(uniqueTextField.variable(), "unique-value") + .next(); - assertTrue(contentlet1FromDB.isPresent()); + APILocator.getContentletAPI().checkin(contentlet_1, APILocator.systemUser(), false); + APILocator.getContentletAPI().checkin(contentlet_2, APILocator.systemUser(), false); - final Optional contentlet2FromDB = APILocator.getContentletAPI() - .findInDb(contentlet_2.getInode()); + final Optional contentlet1FromDB = APILocator.getContentletAPI() + .findInDb(contentlet_1.getInode()); - assertTrue(contentlet2FromDB.isPresent()); + assertTrue(contentlet1FromDB.isPresent()); + final Optional contentlet2FromDB = APILocator.getContentletAPI() + .findInDb(contentlet_2.getInode()); + + assertTrue(contentlet2FromDB.isPresent()); + + if (enabledDataBaseValidation) { + checkUniqueFieldsTable(true, contentType, uniqueTextField, contentlet_1, contentlet_2); + } + } finally { + ESContentletAPIImpl.setFeatureFlagDbUniqueFieldValidation(oldEnabledDataBaseValidation); + } } /** @@ -1249,46 +1339,58 @@ public void savingFieldWithUniqueFieldInDifferentHost() throws DotDataException, * @throws DotSecurityException */ @Test - public void savingFieldWithUniqueFieldInDifferentHostUniquePerSiteToFalse() throws DotDataException, DotSecurityException { + @UseDataProvider("enabledUniqueFieldDatabaseValidation") + public void savingFieldWithUniqueFieldInDifferentHostUniquePerSiteToFalse(final Boolean enabledDataBaseValidation) + throws DotDataException, DotSecurityException { - final ContentType contentType = new ContentTypeDataGen() - .nextPersisted(); + final boolean oldEnabledDataBaseValidation = ESContentletAPIImpl.getFeatureFlagDbUniqueFieldValidation(); - final Field uniqueTextField = new FieldDataGen() - .contentTypeId(contentType.id()) - .unique(true) - .type(TextField.class) - .nextPersisted(); + try { + ESContentletAPIImpl.setFeatureFlagDbUniqueFieldValidation(enabledDataBaseValidation); + final ContentType contentType = new ContentTypeDataGen() + .nextPersisted(); - new FieldVariableDataGen() - .key(UNIQUE_PER_SITE_FIELD_VARIABLE_NAME) - .value("false") - .field(uniqueTextField) - .nextPersisted(); + final Field uniqueTextField = new FieldDataGen() + .contentTypeId(contentType.id()) + .unique(true) + .type(TextField.class) + .nextPersisted(); - final Host host1 = new SiteDataGen().nextPersisted(); - final Host host2 = new SiteDataGen().nextPersisted(); + new FieldVariableDataGen() + .key(UNIQUE_PER_SITE_FIELD_VARIABLE_NAME) + .value("false") + .field(uniqueTextField) + .nextPersisted(); - final Contentlet contentlet_1 = new ContentletDataGen(contentType) - .host(host1) - .setProperty(uniqueTextField.variable(), "unique-value") - .next(); + final Host host1 = new SiteDataGen().nextPersisted(); + final Host host2 = new SiteDataGen().nextPersisted(); - final Contentlet contentlet_2 = new ContentletDataGen(contentType) - .host(host2) - .setProperty(uniqueTextField.variable(), "unique-value") - .next(); + final Contentlet contentlet_1 = new ContentletDataGen(contentType) + .host(host1) + .setProperty(uniqueTextField.variable(), "unique-value") + .next(); - APILocator.getContentletAPI().checkin(contentlet_1, APILocator.systemUser(), false); + final Contentlet contentlet_2 = new ContentletDataGen(contentType) + .host(host2) + .setProperty(uniqueTextField.variable(), "unique-value") + .next(); - try { - APILocator.getContentletAPI().checkin(contentlet_2, APILocator.systemUser(), false); - throw new AssertionError("DotRuntimeException Expected"); - } catch (final DotRuntimeException e) { - final String expectedMessage = String.format("Contentlet with ID 'Unknown/New' [''] has invalid/missing field(s)." - + " - Fields: [UNIQUE]: %s (%s)", uniqueTextField.name(), uniqueTextField.variable()); + APILocator.getContentletAPI().checkin(contentlet_1, APILocator.systemUser(), false); - assertEquals(expectedMessage, e.getMessage()); + try { + APILocator.getContentletAPI().checkin(contentlet_2, APILocator.systemUser(), false); + throw new AssertionError("DotRuntimeException Expected"); + } catch (final DotRuntimeException e) { + final String expectedMessage = String.format("Contentlet with ID 'Unknown/New' [''] has invalid/missing field(s)." + + " - Fields: [UNIQUE]: %s (%s)", uniqueTextField.name(), uniqueTextField.variable()); + + assertEquals(expectedMessage, e.getMessage()); + } + if (enabledDataBaseValidation) { + checkUniqueFieldsTable(false, contentType, uniqueTextField, contentlet_1); + } + } finally { + ESContentletAPIImpl.setFeatureFlagDbUniqueFieldValidation(oldEnabledDataBaseValidation); } } @@ -1635,50 +1737,61 @@ private void checkFilter(final Host host, final VanityUrl vanityURL, final int s * @throws DotSecurityException */ @Test - public void savingFieldWithUniqueFieldInDifferentHostUsingContentTypeHost() throws DotDataException, DotSecurityException { - final ContentType contentType = new ContentTypeDataGen() - .nextPersisted(); + @UseDataProvider("enabledUniqueFieldDatabaseValidation") + public void savingFieldWithUniqueFieldInDifferentHostUsingContentTypeHost(final Boolean enabledDataBaseValidation) + throws DotDataException, DotSecurityException { + final boolean oldEnabledDataBaseValidation = ESContentletAPIImpl.getFeatureFlagDbUniqueFieldValidation(); - final Field uniqueTextField = new FieldDataGen() - .contentTypeId(contentType.id()) - .unique(true) - .type(TextField.class) - .nextPersisted(); + try { + ESContentletAPIImpl.setFeatureFlagDbUniqueFieldValidation(enabledDataBaseValidation); - new FieldVariableDataGen() - .key(UNIQUE_PER_SITE_FIELD_VARIABLE_NAME) - .value("true") - .field(uniqueTextField) - .nextPersisted(); + final ContentType contentType = new ContentTypeDataGen() + .nextPersisted(); - final Host host1 = new SiteDataGen().nextPersisted(); - final Host host2 = new SiteDataGen().nextPersisted(); + final Field uniqueTextField = new FieldDataGen() + .contentTypeId(contentType.id()) + .unique(true) + .type(TextField.class) + .nextPersisted(); - final Contentlet contentlet_1 = new ContentletDataGen(contentType) - .host(host1) - .setProperty(uniqueTextField.variable(), "unique-value") - .next(); + new FieldVariableDataGen() + .key(UNIQUE_PER_SITE_FIELD_VARIABLE_NAME) + .value("true") + .field(uniqueTextField) + .nextPersisted(); - final Contentlet contentlet_2 = new ContentletDataGen(contentType) - .host(host2) - .setProperty(uniqueTextField.variable(), "unique-value") - .next(); + final Host host1 = new SiteDataGen().nextPersisted(); + final Host host2 = new SiteDataGen().nextPersisted(); + + final Contentlet contentlet_1 = new ContentletDataGen(contentType) + .host(host1) + .setProperty(uniqueTextField.variable(), "unique-value") + .next(); - contentlet_2.setHost(null); - contentlet_2.setFolder(null); + final Contentlet contentlet_2 = new ContentletDataGen(contentType) + .host(host2) + .setProperty(uniqueTextField.variable(), "unique-value") + .next(); - APILocator.getContentletAPI().checkin(contentlet_1, APILocator.systemUser(), false); - APILocator.getContentletAPI().checkin(contentlet_2, APILocator.systemUser(), false); + APILocator.getContentletAPI().checkin(contentlet_1, APILocator.systemUser(), false); + APILocator.getContentletAPI().checkin(contentlet_2, APILocator.systemUser(), false); - final Optional contentlet1FromDB = APILocator.getContentletAPI() - .findInDb(contentlet_1.getInode()); + final Optional contentlet1FromDB = APILocator.getContentletAPI() + .findInDb(contentlet_1.getInode()); - assertTrue(contentlet1FromDB.isPresent()); + assertTrue(contentlet1FromDB.isPresent()); - final Optional contentlet2FromDB = APILocator.getContentletAPI() - .findInDb(contentlet_2.getInode()); + final Optional contentlet2FromDB = APILocator.getContentletAPI() + .findInDb(contentlet_2.getInode()); - assertTrue(contentlet2FromDB.isPresent()); + assertTrue(contentlet2FromDB.isPresent()); + + if (enabledDataBaseValidation) { + checkUniqueFieldsTable(true, contentType, uniqueTextField, contentlet_1, contentlet_2); + } + } finally { + ESContentletAPIImpl.setFeatureFlagDbUniqueFieldValidation(oldEnabledDataBaseValidation); + } } @@ -1693,34 +1806,710 @@ public void savingFieldWithUniqueFieldInDifferentHostUsingContentTypeHost() thro * @throws DotSecurityException */ @Test - public void savingFieldWithUniqueFieldInTheSameHostTakingContentTypeHost() throws DotDataException, DotSecurityException { + @UseDataProvider("enabledUniqueFieldDatabaseValidation") + public void savingFieldWithUniqueFieldInTheSameHostTakingContentTypeHost(final Boolean enabledDataBaseValidation) + throws DotDataException, DotSecurityException { + + final boolean oldEnabledDataBaseValidation = ESContentletAPIImpl.getFeatureFlagDbUniqueFieldValidation(); + + try { + ESContentletAPIImpl.setFeatureFlagDbUniqueFieldValidation(enabledDataBaseValidation); + + final Field uniqueTextField = new FieldDataGen() + .unique(true) + .type(TextField.class) + .next(); + + final Host host = new SiteDataGen().nextPersisted(); + + final ContentType contentType = new ContentTypeDataGen() + .host(host) + .field(uniqueTextField) + .nextPersisted(); + + final Contentlet contentlet_1 = new ContentletDataGen(contentType) + .host(host) + .setProperty(uniqueTextField.variable(), "unique-value") + .next(); + + final Contentlet contentlet_2 = new ContentletDataGen(contentType) + .host(host) + .setProperty(uniqueTextField.variable(), "unique-value") + .next(); + + APILocator.getContentletAPI().checkin(contentlet_1, APILocator.systemUser(), false); + + try { + APILocator.getContentletAPI().checkin(contentlet_2, APILocator.systemUser(), false); + throw new AssertionError("DotRuntimeException Expected"); + } catch (final DotRuntimeException e) { + final String expectedMessage = String.format("Contentlet with ID 'Unknown/New' [''] has invalid/missing field(s)." + + " - Fields: [UNIQUE]: %s (%s)", uniqueTextField.name(), uniqueTextField.variable()); + + assertEquals(expectedMessage, e.getMessage()); + } + if (enabledDataBaseValidation) { + checkUniqueFieldsTable(false, contentType, uniqueTextField, contentlet_1); + } + } finally { + ESContentletAPIImpl.setFeatureFlagDbUniqueFieldValidation(oldEnabledDataBaseValidation); + } + } + + /** + * Method to test: {@link ContentletAPI#checkin(Contentlet, User, boolean)} } + * When: + * - Create a {@link ContentType} with a unique field + * - Create a {@link Contentlet} and later try to update it + * Should: Works and create the new version + * + * @throws DotDataException + * @throws DotSecurityException + */ + @Test + @UseDataProvider("enabledUniqueFieldDatabaseValidation") + public void updateContentletWithUniqueFields(final Boolean enabledDataBaseValidation) + throws DotDataException, DotSecurityException { + + final boolean oldEnabledDataBaseValidation = ESContentletAPIImpl.getFeatureFlagDbUniqueFieldValidation(); + + try { + ESContentletAPIImpl.setFeatureFlagDbUniqueFieldValidation(enabledDataBaseValidation); + + final Field uniqueTextField = new FieldDataGen() + .unique(true) + .type(TextField.class) + .next(); + + final Host host = new SiteDataGen().nextPersisted(); + + final ContentType contentType = new ContentTypeDataGen() + .host(host) + .fields(list(uniqueTextField)) + .nextPersisted(); + + final Contentlet contentlet_1 = new ContentletDataGen(contentType) + .host(host) + .setProperty(uniqueTextField.variable(), "unique-value") + .nextPersisted(); + + Contentlet checkout = ContentletDataGen.checkout(contentlet_1); + Contentlet checkin = ContentletDataGen.checkin(checkout); + + List allVersions = APILocator.getContentletAPI().findAllVersions( + APILocator.getIdentifierAPI().find(contentlet_1.getIdentifier()), + APILocator.systemUser(), false); + + assertEquals(2, allVersions.size()); + + final List inodes = allVersions.stream().map(Contentlet::getInode).collect(Collectors.toList()); + assertTrue(inodes.contains(contentlet_1.getInode())); + assertTrue(inodes.contains(checkin.getInode())); + + if (enabledDataBaseValidation) { + checkUniqueFieldsTable(false, contentType, uniqueTextField, contentlet_1); + } + } finally { + ESContentletAPIImpl.setFeatureFlagDbUniqueFieldValidation(oldEnabledDataBaseValidation); + } + } + + /** + * Method to test: {@link ContentletAPI#checkin(Contentlet, User, boolean)} } + * When: + * - Create a {@link ContentType} with a unique field + * - Create a {@link Contentlet} and later create a new version of it with a different value in the unique field + * - Update the Variant value should update the unique_fields table + * Should: Works and create the new version + * + * @throws DotDataException + * @throws DotSecurityException + */ + @Test + public void updateUniqueFieldVariantValue() + throws DotDataException, DotSecurityException { + + final boolean oldEnabledDataBaseValidation = ESContentletAPIImpl.getFeatureFlagDbUniqueFieldValidation(); + + try { + ESContentletAPIImpl.setFeatureFlagDbUniqueFieldValidation(true); + + final Variant specificVariant = new VariantDataGen().nextPersisted(); + + final Field uniqueTextField = new FieldDataGen() + .unique(true) + .type(TextField.class) + .next(); + + final Host host = new SiteDataGen().nextPersisted(); + + final ContentType contentType = new ContentTypeDataGen() + .host(host) + .fields(list(uniqueTextField)) + .nextPersisted(); + + final Contentlet contentlet_1 = new ContentletDataGen(contentType) + .host(host) + .setProperty(uniqueTextField.variable(), "default-unique-value") + .nextPersisted(); + + Contentlet contentletVariant = ContentletDataGen.checkout(contentlet_1); + contentletVariant.setVariantId(specificVariant.name()); + contentletVariant.setProperty(uniqueTextField.variable(), "variant-unique-value"); + + Contentlet checkin_1 = ContentletDataGen.checkin(contentletVariant); + + checkUniqueFieldsTable(false, contentType, uniqueTextField, contentlet_1, checkin_1); + + contentletVariant = ContentletDataGen.checkout(checkin_1); + contentletVariant.setVariantId(specificVariant.name()); + contentletVariant.setProperty(uniqueTextField.variable(), "variant-unique-value-2"); + + Contentlet checkin_2 = ContentletDataGen.checkin(contentletVariant); + + checkUniqueFieldsTable(false, contentType, uniqueTextField, contentlet_1, checkin_2); + + } finally { + ESContentletAPIImpl.setFeatureFlagDbUniqueFieldValidation(oldEnabledDataBaseValidation); + } + } + + /** + * Method to test: {@link ContentletAPI#checkin(Contentlet, User, boolean)} } + * When: + * - Create a {@link ContentType} with a unique field + * - Create 2 {@link Contentlet} in different languages with the same unique value + * Should: Works and create both {@link Contentlet} + * + * @throws DotDataException + * @throws DotSecurityException + */ + @Test + @UseDataProvider("enabledUniqueFieldDatabaseValidation") + public void savingDifferentLanguageContentletWithUniqueFields(final Boolean enabledDataBaseValidation) + throws DotDataException, DotSecurityException { + + final boolean oldEnabledDataBaseValidation = ESContentletAPIImpl.getFeatureFlagDbUniqueFieldValidation(); + + try { + ESContentletAPIImpl.setFeatureFlagDbUniqueFieldValidation(enabledDataBaseValidation); + + final Language language_1 = new LanguageDataGen().nextPersisted(); + final Language language_2 = new LanguageDataGen().nextPersisted(); + + final Field uniqueTextField = new FieldDataGen() + .unique(true) + .type(TextField.class) + .next(); + + + final Host host = new SiteDataGen().nextPersisted(); + + final ContentType contentType = new ContentTypeDataGen() + .host(host) + .fields(list(uniqueTextField)) + .nextPersisted(); + + final Contentlet contentlet_1 = new ContentletDataGen(contentType) + .host(host) + .languageId(language_1.getId()) + .setProperty(uniqueTextField.variable(), "unique-value") + .next(); + + final Contentlet contentlet_2 = new ContentletDataGen(contentType) + .host(host) + .languageId(language_2.getId()) + .setProperty(uniqueTextField.variable(), "unique-value") + .next(); + + APILocator.getContentletAPI().checkin(contentlet_1, APILocator.systemUser(), false); + APILocator.getContentletAPI().checkin(contentlet_2, APILocator.systemUser(), false); + + final Contentlet contentlet_1FromDB = APILocator.getContentletAPI().find(contentlet_1.getInode(), + APILocator.systemUser(), false); + + assertNotNull(contentlet_1FromDB.getIdentifier()); + + final Contentlet contentlet_2FromDB = APILocator.getContentletAPI().find(contentlet_2.getInode(), + APILocator.systemUser(), false); + assertNotNull(contentlet_2FromDB.getIdentifier()); + if (enabledDataBaseValidation) { + checkUniqueFieldsTable(false, contentType, uniqueTextField, contentlet_1, contentlet_2); + } + } finally { + ESContentletAPIImpl.setFeatureFlagDbUniqueFieldValidation(oldEnabledDataBaseValidation); + } + } + + /** + * Method to test: {@link ContentletAPI#checkin(Contentlet, User, boolean)} } + * When: + * - Create a {@link ContentType} with a unique field + * - Create a {@link Contentlet} and later try to create a version in another Variant but change the Unique Field Value + * - Try to create a new Contentlet in DEFAULT value with the unique value in the Variant Version + * Should: Throw a RuntimeException with the message: "Contentlet with id:`Unknown/New` and title:`` has invalid / missing field(s)." + * + * @throws DotDataException + * @throws DotSecurityException + */ + @Test + @UseDataProvider("enabledUniqueFieldDatabaseValidation") + public void createVersionInAnotherVarianttWithUniqueFieldsAndDifferentValue(final Boolean enabledDataBaseValidation) + throws DotDataException, DotSecurityException, InterruptedException { + final boolean oldEnabledDataBaseValidation = ESContentletAPIImpl.getFeatureFlagDbUniqueFieldValidation(); + + try { + ESContentletAPIImpl.setFeatureFlagDbUniqueFieldValidation(enabledDataBaseValidation); + final Variant variant = new VariantDataGen().nextPersisted(); + final Language language = new LanguageDataGen().nextPersisted(); + + final Field uniqueTextField = new FieldDataGen() + .unique(true) + .type(TextField.class) + .next(); + + final Host host = new SiteDataGen().nextPersisted(); + + final ContentType contentType = new ContentTypeDataGen() + .host(host) + .fields(list(uniqueTextField)) + .nextPersisted(); + + final String defaultVersionValue = "default-unique-value"; + final String variantVersionValue = "variant-unique-value"; + + final Contentlet contentlet_1 = new ContentletDataGen(contentType) + .host(host) + .setProperty(uniqueTextField.variable(), defaultVersionValue) + .languageId(language.getId()) + .nextPersisted(); + + final Contentlet contentletVariantVersion = ContentletDataGen.checkout(contentlet_1); + contentletVariantVersion.setProperty(uniqueTextField.variable(), variantVersionValue); + contentletVariantVersion.setVariantId(variant.name()); + + APILocator.getContentletAPI().checkin(contentletVariantVersion, APILocator.systemUser(), false); + + final Contentlet contentlet_2 = new ContentletDataGen(contentType) + .host(host) + .setProperty(uniqueTextField.variable(), defaultVersionValue) + .languageId(language.getId()) + .next(); + + try { + APILocator.getContentletAPI().checkin(contentlet_2, APILocator.systemUser(), false); + throw new AssertionError("DotRuntimeException Expected"); + } catch (final DotRuntimeException e) { + final String expectedMessage = String.format("Contentlet with ID 'Unknown/New' [''] has invalid/missing field(s)." + + " - Fields: [UNIQUE]: %s (%s)", uniqueTextField.name(), uniqueTextField.variable()); + + assertEquals(expectedMessage, e.getMessage()); + } + + final Contentlet contentlet_3 = new ContentletDataGen(contentType) + .host(host) + .setProperty(uniqueTextField.variable(), variantVersionValue) + .variant(variant) + .languageId(language.getId()) + .next(); + + try { + APILocator.getContentletAPI().checkin(contentlet_3, APILocator.systemUser(), false); + throw new AssertionError("DotRuntimeException Expected"); + } catch (final DotRuntimeException e) { + final String expectedMessage = String.format("Contentlet with ID 'Unknown/New' [''] has invalid/missing field(s)." + + " - Fields: [UNIQUE]: %s (%s)", uniqueTextField.name(), uniqueTextField.variable()); + + assertEquals(expectedMessage, e.getMessage()); + } + } finally { + ESContentletAPIImpl.setFeatureFlagDbUniqueFieldValidation(oldEnabledDataBaseValidation); + } + } + + /** + * Method to test: {@link ContentletAPI#checkin(Contentlet, User, boolean)} } + * When: + * - Create a {@link ContentType} with a unique field + * - Create a {@link Contentlet}, set unique-value as value to the unique field. + * - Try to Create a new {@link Contentlet} using the unique-value should throw a Duplicated Exception. + * - Update the {@link Contentlet} and change the value of the unique-field for 'new-unique-value' + * - Try to create the new {@link Contentlet} again using the 'unique-value', should create the Contentlet + * + * @throws DotDataException + * @throws DotSecurityException + */ + @Test + @UseDataProvider("enabledUniqueFieldDatabaseValidation") + public void reUseUniqueValues(final Boolean enabledDataBaseValidation) + throws DotDataException, DotSecurityException, InterruptedException { + final boolean oldEnabledDataBaseValidation = ESContentletAPIImpl.getFeatureFlagDbUniqueFieldValidation(); + + try { + ESContentletAPIImpl.setFeatureFlagDbUniqueFieldValidation(enabledDataBaseValidation); + final Language language = new LanguageDataGen().nextPersisted(); + + final Field uniqueTextField = new FieldDataGen() + .unique(true) + .type(TextField.class) + .next(); + + final Host host = new SiteDataGen().nextPersisted(); + + final ContentType contentType = new ContentTypeDataGen() + .host(host) + .fields(list(uniqueTextField)) + .nextPersisted(); + + final String uniqueValue = "unique-value"; + final String newUniqueValue = "new-unique-value"; + + final Contentlet contentlet_1 = new ContentletDataGen(contentType) + .host(host) + .setProperty(uniqueTextField.variable(), uniqueValue) + .languageId(language.getId()) + .nextPersisted(); + + Contentlet contentlet_2 = new ContentletDataGen(contentType) + .host(host) + .setProperty(uniqueTextField.variable(), uniqueValue) + .languageId(language.getId()) + .next(); + + try { + APILocator.getContentletAPI().checkin(contentlet_2, APILocator.systemUser(), false); + throw new AssertionError("DotRuntimeException Expected"); + } catch (final DotRuntimeException e) { + final String expectedMessage = String.format("Contentlet with ID 'Unknown/New' [''] has invalid/missing field(s)." + + " - Fields: [UNIQUE]: %s (%s)", uniqueTextField.name(), uniqueTextField.variable()); + + assertEquals(expectedMessage, e.getMessage()); + } + + final Contentlet checkout = ContentletDataGen.checkout(contentlet_1); + checkout.setProperty(uniqueTextField.variable(), newUniqueValue); + final Contentlet contentletUpdate = ContentletDataGen.checkin(checkout); + + APILocator.getContentletAPI().checkin(contentlet_2, APILocator.systemUser(), false); + + Contentlet contentlet_2FromDB = APILocator.getContentletAPI().find(contentlet_2.getInode(), + APILocator.systemUser(), false); + + assertNotNull(contentlet_2FromDB); + assertEquals(contentlet_2.getIdentifier(), contentlet_2FromDB.getIdentifier()); + + if (enabledDataBaseValidation) { + checkUniqueFieldsTable(false, contentType, uniqueTextField, contentletUpdate, + contentlet_2FromDB); + } + } finally { + ESContentletAPIImpl.setFeatureFlagDbUniqueFieldValidation(oldEnabledDataBaseValidation); + } + } + + /** + * Method to test: {@link ContentletAPI#checkin(Contentlet, User, boolean)} } + * When: + * - Create a {@link ContentType} with a unique field + * - Create a {@link Contentlet}, set unique-value as value to the unique field. + * - Archive the {@link Contentlet}, created in the previous step. + * - Try to Create a new {@link Contentlet} using the unique-value + * Should: throw a Duplicated Exception + * + * @throws DotDataException + * @throws DotSecurityException + */ + @Test + @UseDataProvider("enabledUniqueFieldDatabaseValidation") + public void uniqueFieldWithArchiveContentlet(final Boolean enabledDataBaseValidation) + throws DotDataException, DotSecurityException, InterruptedException { + final boolean oldEnabledDataBaseValidation = ESContentletAPIImpl.getFeatureFlagDbUniqueFieldValidation(); + + try { + ESContentletAPIImpl.setFeatureFlagDbUniqueFieldValidation(enabledDataBaseValidation); + final Language language = new LanguageDataGen().nextPersisted(); + + final Field uniqueTextField = new FieldDataGen() + .unique(true) + .type(TextField.class) + .next(); + + final Host host = new SiteDataGen().nextPersisted(); + + final ContentType contentType = new ContentTypeDataGen() + .host(host) + .fields(list(uniqueTextField)) + .nextPersisted(); + + final String uniqueValue = "unique-value"; + + final Contentlet contentlet_1 = new ContentletDataGen(contentType) + .host(host) + .setProperty(uniqueTextField.variable(), uniqueValue) + .languageId(language.getId()) + .nextPersisted(); + + ContentletDataGen.archive(contentlet_1); + + Contentlet contentlet_2 = new ContentletDataGen(contentType) + .host(host) + .setProperty(uniqueTextField.variable(), uniqueValue) + .languageId(language.getId()) + .next(); + + try { + APILocator.getContentletAPI().checkin(contentlet_2, APILocator.systemUser(), false); + throw new AssertionError("DotRuntimeException Expected"); + } catch (final DotRuntimeException e) { + final String expectedMessage = String.format("Contentlet with ID 'Unknown/New' [''] has invalid/missing field(s)." + + " - Fields: [UNIQUE]: %s (%s)", uniqueTextField.name(), uniqueTextField.variable()); + + assertEquals(expectedMessage, e.getMessage()); + } + + if (enabledDataBaseValidation) { + checkUniqueFieldsTable(false, contentType, uniqueTextField, contentlet_1); + } + } finally { + ESContentletAPIImpl.setFeatureFlagDbUniqueFieldValidation(oldEnabledDataBaseValidation); + } + } + + /** + * Method to test: {@link ContentletAPI#checkin(Contentlet, User, boolean)} } + * When: + * - Create a {@link ContentType} with Text Fields + * - Create a couple of Contentlet with the same value in this ETxt Field + * - Change the field to be unique + * - Populate manually the unique_fields table + * - Update one of the Contentlet and the unique_fields table should be updated too, but the register + * is not going to be removed because we have another COntentlet with the same value + * Should: Update the Contentlet and uodate the unique_fields table right + * + * This can happen if the Contentlets with the duplicated values exists before the Upgrade than contains the new Database validation + * + * @throws DotDataException + * @throws DotSecurityException + */ + @Test + public void updateContentletWithDuplicateValuesInUniqueFields() + throws DotDataException, DotSecurityException, InterruptedException, IOException { + final boolean oldEnabledDataBaseValidation = ESContentletAPIImpl.getFeatureFlagDbUniqueFieldValidation(); + + try { + ESContentletAPIImpl.setFeatureFlagDbUniqueFieldValidation(true); + + final Language language = new LanguageDataGen().nextPersisted(); + + final Field uniqueTextField = new FieldDataGen() + .type(TextField.class) + .next(); + + final Host host = new SiteDataGen().nextPersisted(); + + final ContentType contentType = new ContentTypeDataGen() + .host(host) + .fields(list(uniqueTextField)) + .nextPersisted(); + + final String uniqueVersionValue = "unique-value"; + + final Contentlet contentlet_1 = new ContentletDataGen(contentType) + .host(host) + .setProperty(uniqueTextField.variable(), uniqueVersionValue) + .languageId(language.getId()) + .nextPersisted(); + + final Contentlet contentlet_2 = new ContentletDataGen(contentType) + .host(host) + .setProperty(uniqueTextField.variable(), uniqueVersionValue) + .languageId(language.getId()) + .nextPersisted(); + + final Field uniqueTextFieldFromDB = APILocator.getContentTypeFieldAPI() + .byContentTypeAndVar(contentType, uniqueTextField.variable()); + + final ImmutableTextField uniqueFieldUpdated = ImmutableTextField.builder() + .from(uniqueTextField) + .contentTypeId(contentType.id()) + .unique(true) + .build(); + + APILocator.getContentTypeFieldAPI().save(uniqueFieldUpdated, APILocator.systemUser()); + + Map uniqueFieldCriteriaMap = Map.of( + "contentTypeID", contentType.id(), + "fieldVariableName", uniqueTextFieldFromDB.variable(), + "fieldValue", uniqueVersionValue.toString(), + "languageId", language.getId(), + "hostId", host.getIdentifier(), + "uniquePerSite", true, + "variant", VariantAPI.DEFAULT_VARIANT.name() + ); + + final Map supportingValues = new HashMap<>(uniqueFieldCriteriaMap); + supportingValues.put("contentletsId", CollectionsUtils.list(contentlet_1.getIdentifier(), + contentlet_2.getIdentifier())); + supportingValues.put("uniquePerSite", false); + + final String hash = StringUtils.hashText(contentType.id() + uniqueTextFieldFromDB.variable() + + language.getId() + uniqueVersionValue + host.getIdentifier()); + + new DotConnect().setSQL("INSERT INTO unique_fields (unique_key_val, supporting_values) VALUES(?, ?)") + .addParam(hash) + .addJSONParam(supportingValues) + .loadObjectResults(); + + final Contentlet checkout = ContentletDataGen.checkout(contentlet_1); + checkout.setProperty(uniqueTextField.variable(), "another-value"); + + APILocator.getContentletAPI().checkin(checkout, APILocator.systemUser(), false); + + checkContentletInUniqueFieldsTable(contentlet_1); + checkContentletInUniqueFieldsTable(contentlet_2); + } finally { + ESContentletAPIImpl.setFeatureFlagDbUniqueFieldValidation(oldEnabledDataBaseValidation); + } + } + + private static void checkContentletInUniqueFieldsTable(final Contentlet contentlet) throws DotDataException, IOException { + final List> result_1 = new DotConnect() + .setSQL("SELECT * FROM unique_fields WHERE supporting_values->'contentletsId' @> ?::jsonb") + .addParam("\"" + contentlet.getIdentifier() + "\"") + .loadObjectResults(); + + assertEquals(1, result_1.size()); + + final PGobject supportingValues = (PGobject) result_1.get(0).get("supporting_values"); + final Map supportingValuesMap = JsonUtil.getJsonFromString(supportingValues.getValue()); + final List contentletsId = (List) supportingValuesMap.get("contentletsId"); + + assertEquals(1, contentletsId.size()); + assertEquals(contentlet.getIdentifier(), contentletsId.get(0)); + } + + /** + * Method to test: {@link ContentletAPI#checkin(Contentlet, User, boolean)} } + * When: + * - Create a {@link ContentType} with a unique field + * - Create a {@link Contentlet} and later try to create a version in another Variant + * Should: Works and create the new version + * + * @throws DotDataException + * @throws DotSecurityException + */ + @Test + @UseDataProvider("enabledUniqueFieldDatabaseValidation") + public void createVersionInAnotherVarianttWithUniqueFields(final Boolean enabledDataBaseValidation) + throws DotDataException, DotSecurityException { + final boolean oldEnabledDataBaseValidation = ESContentletAPIImpl.getFeatureFlagDbUniqueFieldValidation(); + + try { + ESContentletAPIImpl.setFeatureFlagDbUniqueFieldValidation(enabledDataBaseValidation); + + + final Variant variant = new VariantDataGen().nextPersisted(); + final Language language = new LanguageDataGen().nextPersisted(); + + final Field uniqueTextField = new FieldDataGen() + .unique(true) + .type(TextField.class) + .next(); + + final Field titleTextField = new FieldDataGen() + .type(TextField.class) + .next(); + + final Host host = new SiteDataGen().nextPersisted(); + + final ContentType contentType = new ContentTypeDataGen() + .host(host) + .fields(list(uniqueTextField, titleTextField)) + .nextPersisted(); + + final Contentlet contentlet_1 = new ContentletDataGen(contentType) + .host(host) + .setProperty(uniqueTextField.variable(), "default-unique-value") + .setProperty(titleTextField.variable(), "Title") + .languageId(language.getId()) + .nextPersisted(); + + Contentlet checkout = ContentletDataGen.checkout(contentlet_1); + checkout.setProperty(uniqueTextField.variable(), "variant-unique-value"); + checkout.setProperty(titleTextField.variable(), "Title2"); + checkout.setVariantId(variant.name()); + + final Contentlet contentletVariantVersion = ContentletDataGen.checkin(checkout); + + List allVersions = APILocator.getContentletAPI().findAllVersions( + APILocator.getIdentifierAPI().find(contentlet_1.getIdentifier()), + APILocator.systemUser(), false); + + assertEquals(2, allVersions.size()); + + final Contentlet contentlet_1FromDB = allVersions.stream() + .filter(contentlet -> contentlet.getInode().equals(contentlet_1.getInode())) + .findFirst() + .orElseThrow(); + + assertEquals(contentlet_1.getVariantId(), contentlet_1FromDB.getVariantId()); + + final Contentlet contentlet_2FromDB = allVersions.stream() + .filter(contentlet -> contentlet.getInode().equals(contentletVariantVersion.getInode())) + .findFirst() + .orElseThrow(); + + assertEquals(contentletVariantVersion.getVariantId(), contentlet_2FromDB.getVariantId()); + } finally { + ESContentletAPIImpl.setFeatureFlagDbUniqueFieldValidation(oldEnabledDataBaseValidation); + } + } + + /** + * Method to test: {@link ContentletAPI#checkin(Contentlet, User, boolean)} } + * When: + * - Create a {@link ContentType} with a unique field + * - Create 2 {@link Contentlet} in different Variants with the same unique value + * Should: throws Exception + * + * @throws DotDataException + * @throws DotSecurityException + */ + @Test + public void savingDifferentVariantContentletWithUniqueFields() throws DotDataException, DotSecurityException, InterruptedException { + + final Language language = new LanguageDataGen().nextPersisted(); + + final Variant variant_1 = new VariantDataGen().nextPersisted(); + final Variant variant_2 = new VariantDataGen().nextPersisted(); + final Field uniqueTextField = new FieldDataGen() .unique(true) .type(TextField.class) .next(); + final Host host = new SiteDataGen().nextPersisted(); final ContentType contentType = new ContentTypeDataGen() .host(host) - .field(uniqueTextField) + .fields(list(uniqueTextField)) .nextPersisted(); final Contentlet contentlet_1 = new ContentletDataGen(contentType) .host(host) + .languageId(language.getId()) + .variant(variant_1) .setProperty(uniqueTextField.variable(), "unique-value") - .next(); + .nextPersisted(); final Contentlet contentlet_2 = new ContentletDataGen(contentType) .host(host) + .languageId(language.getId()) + .variant(variant_2) .setProperty(uniqueTextField.variable(), "unique-value") .next(); - contentlet_2.setHost(null); - contentlet_2.setFolder(null); - - APILocator.getContentletAPI().checkin(contentlet_1, APILocator.systemUser(), false); - try { APILocator.getContentletAPI().checkin(contentlet_2, APILocator.systemUser(), false); throw new AssertionError("DotRuntimeException Expected"); diff --git a/dotcms-integration/src/test/java/com/dotcms/contenttype/business/uniquefields/extratable/DBUniqueFieldValidationStrategyTest.java b/dotcms-integration/src/test/java/com/dotcms/contenttype/business/uniquefields/extratable/DBUniqueFieldValidationStrategyTest.java new file mode 100644 index 000000000000..59e25307cbe3 --- /dev/null +++ b/dotcms-integration/src/test/java/com/dotcms/contenttype/business/uniquefields/extratable/DBUniqueFieldValidationStrategyTest.java @@ -0,0 +1,845 @@ +package com.dotcms.contenttype.business.uniquefields.extratable; + +import com.dotcms.contenttype.business.UniqueFieldValueDuplicatedException; +import com.dotcms.contenttype.model.field.Field; +import com.dotcms.contenttype.model.field.TextField; +import com.dotcms.contenttype.model.type.ContentType; +import com.dotcms.datagen.*; +import com.dotcms.util.IntegrationTestInitService; +import com.dotcms.util.JsonUtil; +import com.dotcms.variant.VariantAPI; +import com.dotmarketing.beans.Host; +import com.dotmarketing.common.db.DotConnect; +import com.dotmarketing.exception.DotDataException; +import com.dotmarketing.exception.DotSecurityException; +import com.dotmarketing.portlets.contentlet.model.Contentlet; +import com.dotmarketing.portlets.languagesmanager.model.Language; +import com.dotmarketing.util.StringUtils; +import com.dotmarketing.util.UUIDGenerator; +import com.liferay.util.StringPool; +import net.bytebuddy.utility.RandomString; +import org.junit.BeforeClass; +import org.junit.Test; + +import java.io.IOException; +import java.util.Collection; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import static com.dotcms.content.elasticsearch.business.ESContentletAPIImpl.UNIQUE_PER_SITE_FIELD_VARIABLE_NAME; +import static com.dotcms.util.CollectionsUtils.list; +import static com.dotmarketing.portlets.contentlet.model.Contentlet.IDENTIFIER_KEY; +import static com.dotmarketing.portlets.contentlet.model.Contentlet.INODE_KEY; +import static org.junit.Assert.*; + +public class DBUniqueFieldValidationStrategyTest { + + static UniqueFieldDataBaseUtil uniqueFieldDataBaseUtil; + + @BeforeClass + public static void prepare() throws Exception { + IntegrationTestInitService.getInstance().init(); + uniqueFieldDataBaseUtil = new UniqueFieldDataBaseUtil(); + + //TODO: Remove this when the whole change is done + try { + new DotConnect().setSQL("CREATE TABLE IF NOT EXISTS unique_fields (" + + "unique_key_val VARCHAR(64) PRIMARY KEY," + + "supporting_values JSONB" + + " )").loadObjectResults(); + } catch (DotDataException e) { + throw new RuntimeException(e); + } + } + + /** + * Method to test: {@link DBUniqueFieldValidationStrategy#validate(Contentlet, Field)} + * When: Called the method with the right parameters + * Should: Insert a register in the unique_fields table + */ + @Test + public void insert() throws DotDataException, UniqueFieldValueDuplicatedException, DotSecurityException { + final Field field = new FieldDataGen().type(TextField.class).unique(true).next(); + final ContentType contentType = new ContentTypeDataGen().field(field).nextPersisted(); + final Object value = new RandomString().nextString(); + final Language language = new LanguageDataGen().nextPersisted(); + final Host site = new SiteDataGen().nextPersisted(); + + final Contentlet contentlet = new ContentletDataGen(contentType) + .setProperty(field.variable(), value) + .host(site) + .languageId(language.getId()) + .next(); + + final DBUniqueFieldValidationStrategy extraTableUniqueFieldValidationStrategy = + new DBUniqueFieldValidationStrategy(uniqueFieldDataBaseUtil); + extraTableUniqueFieldValidationStrategy.validate(contentlet, field); + + final UniqueFieldCriteria uniqueFieldCriteria = new UniqueFieldCriteria.Builder() + .setContentType(contentType) + .setField(field) + .setValue(value) + .setLanguage(language) + .setSite(site) + .setVariantName(VariantAPI.DEFAULT_VARIANT.name()) + .build(); + + validateAfterInsert(uniqueFieldCriteria, contentlet); + } + + /** + * Method to test: {@link DBUniqueFieldValidationStrategy#validate(Contentlet, Field)} + * When: Called the method with a 'unique_key_val' duplicated + * Should: Throw a {@link UniqueFieldValueDuplicatedException} + */ + @Test + public void tryToInsertDuplicated() throws DotDataException, UniqueFieldValueDuplicatedException, DotSecurityException { + final Field uniqueField = new FieldDataGen().type(TextField.class).unique(true).next(); + final ContentType contentType = new ContentTypeDataGen().field(uniqueField).nextPersisted(); + final Object value = "UniqueValue" + System.currentTimeMillis(); + final Language language = new LanguageDataGen().nextPersisted(); + final Host site = new SiteDataGen().nextPersisted(); + + final Contentlet contentlet = new ContentletDataGen(contentType) + .setProperty(uniqueField.variable(), value) + .host(site) + .languageId(language.getId()) + .next(); + + + final DBUniqueFieldValidationStrategy extraTableUniqueFieldValidationStrategy = + new DBUniqueFieldValidationStrategy(uniqueFieldDataBaseUtil); + extraTableUniqueFieldValidationStrategy.validate(contentlet, uniqueField); + + final String hash = StringUtils.hashText(contentType.id() + uniqueField.variable() + language.getId() + value); + + final int countBefore = Integer.parseInt(new DotConnect() + .setSQL("SELECT COUNT(*) as count FROM unique_fields WHERE unique_key_val = ?") + .addParam(hash).loadObjectResults().get(0).get("count").toString()); + try { + + extraTableUniqueFieldValidationStrategy.validate(contentlet, uniqueField); + throw new AssertionError("UniqueFieldValueDupliacatedException expected"); + } catch (UniqueFieldValueDuplicatedException e) { + + final int countAfter = Integer.parseInt(new DotConnect() + .setSQL("SELECT COUNT(*) as count FROM unique_fields WHERE unique_key_val = ?") + .addParam(hash).loadObjectResults().get(0).get("count").toString()); + + assertEquals(countBefore, countAfter); + } + } + + /** + * Method to test: {@link DBUniqueFieldValidationStrategy#validate(Contentlet, Field)} + * When: Called the method with a field with uniquePerSite set to true + * Should: Allow insert the same values in different Host + */ + @Test + public void insertWithUniquePerSiteSetToTrue() throws DotDataException, UniqueFieldValueDuplicatedException, DotSecurityException { + final Field uniqueField = new FieldDataGen().type(TextField.class).unique(true).next(); + final ContentType contentType = new ContentTypeDataGen().field(uniqueField).nextPersisted(); + final Language language = new LanguageDataGen().nextPersisted(); + final Host site = new SiteDataGen().nextPersisted(); + final Host site_2 = new SiteDataGen().nextPersisted(); + final String uniqueFieldVariable = uniqueField.variable(); + final String uniqueValue = "UniqueValue" + System.currentTimeMillis(); + + new FieldVariableDataGen() + .key(UNIQUE_PER_SITE_FIELD_VARIABLE_NAME) + .value("true") + .field(contentType.fields().stream() + .filter(field -> field.variable().equals(uniqueFieldVariable)) + .limit(1) + .findFirst() + .orElseThrow()) + .nextPersisted(); + + final UniqueFieldCriteria uniqueFieldCriteria_1 = new UniqueFieldCriteria.Builder() + .setContentType(contentType) + .setField(uniqueField) + .setValue(uniqueValue) + .setLanguage(language) + .setSite(site) + .setVariantName(VariantAPI.DEFAULT_VARIANT.name()) + .build(); + + final Contentlet contentlet_1 = new ContentletDataGen(contentType) + .setProperty(uniqueFieldVariable, uniqueValue) + .host(site) + .languageId(language.getId()) + .next(); + + final DBUniqueFieldValidationStrategy extraTableUniqueFieldValidationStrategy = + new DBUniqueFieldValidationStrategy(uniqueFieldDataBaseUtil); + extraTableUniqueFieldValidationStrategy.validate(contentlet_1, uniqueField); + + final UniqueFieldCriteria uniqueFieldCriteria_2 = new UniqueFieldCriteria.Builder() + .setContentType(contentType) + .setField(uniqueField) + .setValue(uniqueValue) + .setLanguage(language) + .setSite(site_2) + .setVariantName(VariantAPI.DEFAULT_VARIANT.name()) + .build(); + + final Contentlet contentlet_2 = new ContentletDataGen(contentType) + .setProperty(uniqueFieldVariable, uniqueValue) + .host(site_2) + .languageId(language.getId()) + .next(); + + extraTableUniqueFieldValidationStrategy.validate(contentlet_2, uniqueField); + + final List> results = new DotConnect() + .setSQL("SELECT * FROM unique_fields WHERE supporting_values->>'contentTypeID' = ?") + .addParam(contentType.id()) + .loadObjectResults(); + + assertEquals(2, results.size()); + + final UniqueFieldCriteria[] uniqueFieldCriterias = new UniqueFieldCriteria[]{uniqueFieldCriteria_1, uniqueFieldCriteria_2}; + final Contentlet[] contentlets = new Contentlet[]{contentlet_1, contentlet_2}; + final Host[] sites = new Host[]{site, site_2}; + + for (int i =0; i < results.size(); i++) { + Map result = results.get(i); + final Map mapExpected = new HashMap<>(uniqueFieldCriterias[i].toMap()); + mapExpected.put("contentletsId", list(contentlets[i].getIdentifier())); + mapExpected.put("uniquePerSite", true); + + final String valueToHash = contentType.id() + uniqueField.variable() + language.getId() + uniqueValue + + sites[i].getIdentifier(); + assertEquals(StringUtils.hashText(valueToHash), result.get("unique_key_val")); + } + } + + /** + * Method to test: {@link DBUniqueFieldValidationStrategy#validate(Contentlet, Field)} + * When: Called the method with a Not Unique Field + * Should: thrown an {@link IllegalArgumentException} + */ + @Test + public void insertNotUniqueField() throws DotDataException, UniqueFieldValueDuplicatedException, DotSecurityException { + final Field notUniqueField = new FieldDataGen().type(TextField.class).next(); + final ContentType contentType = new ContentTypeDataGen().field(notUniqueField).nextPersisted(); + final Object value = "UniqueValue" + System.currentTimeMillis(); + final Language language = new LanguageDataGen().nextPersisted(); + final Host site = new SiteDataGen().nextPersisted(); + + final Contentlet contentlet = new ContentletDataGen(contentType) + .setProperty(notUniqueField.variable(), value) + .languageId(language.getId()) + .host(site) + .next(); + + try { + + final DBUniqueFieldValidationStrategy extraTableUniqueFieldValidationStrategy = + new DBUniqueFieldValidationStrategy(uniqueFieldDataBaseUtil); + extraTableUniqueFieldValidationStrategy.validate(contentlet, notUniqueField); + throw new AssertionError("IllegalArgumentException Expected"); + } catch (IllegalArgumentException e) { + //expected + assertEquals("The Field " + notUniqueField.variable() + " is not unique", e.getMessage()); + } + } + + private static void validateAfterInsert(UniqueFieldCriteria uniqueFieldCriteria, + Contentlet... contentlets) throws DotDataException { + + final ContentType contentType = uniqueFieldCriteria.contentType(); + final Field field =uniqueFieldCriteria.field(); + final Language language = uniqueFieldCriteria.language(); + final Object value = uniqueFieldCriteria.value(); + + final List> results = new DotConnect() + .setSQL("SELECT * FROM unique_fields WHERE supporting_values->>'contentTypeID' = ? AND " + + "supporting_values->>'fieldVariableName' = ? AND supporting_values->>'fieldValue' = ? AND " + + "(supporting_values->>'languageId')::numeric = ?") + .addParam(contentType.id()) + .addParam(field.variable()) + .addParam(value) + .addParam(language.getId()) + .loadObjectResults(); + + assertEquals(contentlets.length, results.size()); + + for (Contentlet contentlet : contentlets) { + final Map mapExpected = new HashMap<>(uniqueFieldCriteria.toMap()); + + mapExpected.put("contentletsId", list(contentlet.getIdentifier())); + mapExpected.put("uniquePerSite", false); + + final String valueToHash = contentType.id() + field.variable() + language.getId() + value; + assertEquals(StringUtils.hashText(valueToHash), results.get(0).get("unique_key_val")); + } + } + + /** + * Method to test: {@link DBUniqueFieldValidationStrategy#validate(Contentlet, Field)} + * When: Called the method twice with different Content Type + * Should: Insert a register in the unique_fields table + */ + @Test + public void insertWithDifferentContentType() throws DotDataException, UniqueFieldValueDuplicatedException, DotSecurityException { + final Field field_1 = new FieldDataGen().type(TextField.class).velocityVarName("unique").unique(true).next(); + final ContentType contentType_1 = new ContentTypeDataGen().field(field_1).nextPersisted(); + final Object value = "UniqueValue" + System.currentTimeMillis(); + final Language language = new LanguageDataGen().nextPersisted(); + final Host site = new SiteDataGen().nextPersisted(); + + final UniqueFieldCriteria uniqueFieldCriteria_1 = new UniqueFieldCriteria.Builder() + .setContentType(contentType_1) + .setField(field_1) + .setValue(value) + .setLanguage(language) + .setSite(site) + .setVariantName(VariantAPI.DEFAULT_VARIANT.name()) + .build(); + + final Contentlet contentlet_1 = new ContentletDataGen(contentType_1) + .setProperty(field_1.variable(), value) + .host(site) + .languageId(language.getId()) + .next(); + + final DBUniqueFieldValidationStrategy extraTableUniqueFieldValidationStrategy = + new DBUniqueFieldValidationStrategy(uniqueFieldDataBaseUtil); + extraTableUniqueFieldValidationStrategy.validate(contentlet_1, field_1); + + validateAfterInsert(uniqueFieldCriteria_1, contentlet_1); + + final Field field_2 = new FieldDataGen().type(TextField.class).velocityVarName("unique").unique(true).next(); + final ContentType contentType_2 = new ContentTypeDataGen().field(field_2).nextPersisted(); + + final Contentlet contentlet_2 = new ContentletDataGen(contentType_2) + .setProperty(field_1.variable(), value) + .host(site) + .languageId(language.getId()) + .next(); + + final UniqueFieldCriteria uniqueFieldCriteria_2 = new UniqueFieldCriteria.Builder() + .setContentType(contentType_2) + .setField(field_2) + .setValue(value) + .setLanguage(language) + .setSite(site) + .setVariantName(VariantAPI.DEFAULT_VARIANT.name()) + .build(); + + extraTableUniqueFieldValidationStrategy.validate(contentlet_2, field_1); + validateAfterInsert(uniqueFieldCriteria_2, contentlet_2); + } + + /** + * Method to test: {@link DBUniqueFieldValidationStrategy#validate(Contentlet, Field)} + * When: Called the method twice with different Field + * Should: Insert a register in the unique_fields table + */ + @Test + public void insertWithDifferentField() throws DotDataException, UniqueFieldValueDuplicatedException, DotSecurityException { + final Field field_1 = new FieldDataGen().type(TextField.class).unique(true) + .velocityVarName("field1" + System.currentTimeMillis()).next(); + final Field field_2 = new FieldDataGen().type(TextField.class).unique(true) + .velocityVarName("field2" + System.currentTimeMillis()).next(); + final ContentType contentType = new ContentTypeDataGen().field(field_1).field(field_2).nextPersisted(); + final Language language = new LanguageDataGen().nextPersisted(); + final Host site = new SiteDataGen().nextPersisted(); + + final String uniqueValue = "UniqueValue" + System.currentTimeMillis(); + + final Contentlet contentlet = new ContentletDataGen(contentType) + .setProperty(field_1.variable(), uniqueValue) + .setProperty(field_2.variable(), uniqueValue) + .languageId(language.getId()) + .host(site) + .next(); + + final UniqueFieldCriteria uniqueFieldCriteria_1 = new UniqueFieldCriteria.Builder() + .setContentType(contentType) + .setField(field_1) + .setValue(uniqueValue) + .setLanguage(language) + .setSite(site) + .setVariantName(VariantAPI.DEFAULT_VARIANT.name()) + .build(); + + final DBUniqueFieldValidationStrategy extraTableUniqueFieldValidationStrategy = + new DBUniqueFieldValidationStrategy(uniqueFieldDataBaseUtil); + extraTableUniqueFieldValidationStrategy.validate(contentlet, field_1); + + final UniqueFieldCriteria uniqueFieldCriteria_2 = new UniqueFieldCriteria.Builder() + .setContentType(contentType) + .setField(field_2) + .setValue(uniqueValue) + .setLanguage(language) + .setSite(site) + .setVariantName(VariantAPI.DEFAULT_VARIANT.name()) + .build(); + + extraTableUniqueFieldValidationStrategy.validate(contentlet, field_2); + + validateAfterInsert(uniqueFieldCriteria_1, contentlet); + validateAfterInsert(uniqueFieldCriteria_2, contentlet); + } + + /** + * Method to test: {@link DBUniqueFieldValidationStrategy#validate(Contentlet, Field)} + * When: Called the method twice with different Value + * Should: Insert a register in the unique_fields table + */ + @Test + public void insertWithDifferentValue() throws DotDataException, UniqueFieldValueDuplicatedException, DotSecurityException { + final Field field = new FieldDataGen().type(TextField.class).unique(true) + .velocityVarName("field1" + System.currentTimeMillis()).next(); + final ContentType contentType = new ContentTypeDataGen().field(field).nextPersisted(); + final Language language = new LanguageDataGen().nextPersisted(); + final Host site = new SiteDataGen().nextPersisted(); + + final String uniqueValue_1 = "UniqueValue1" + System.currentTimeMillis(); + final String uniqueValue_2 = "UniqueValue2" + System.currentTimeMillis(); + + final String id = UUIDGenerator.generateUuid(); + + final Contentlet contentlet = new ContentletDataGen(contentType) + .setProperty(field.variable(), uniqueValue_1) + .setProperty(IDENTIFIER_KEY, id) + .host(site) + .languageId(language.getId()) + .next(); + + final UniqueFieldCriteria uniqueFieldCriteria_1 = new UniqueFieldCriteria.Builder() + .setContentType(contentType) + .setField(field) + .setValue(uniqueValue_1) + .setLanguage(language) + .setSite(site) + .setVariantName(VariantAPI.DEFAULT_VARIANT.name()) + .build(); + + final DBUniqueFieldValidationStrategy extraTableUniqueFieldValidationStrategy = + new DBUniqueFieldValidationStrategy(uniqueFieldDataBaseUtil); + extraTableUniqueFieldValidationStrategy.validate(contentlet, field); + + final UniqueFieldCriteria uniqueFieldCriteria_2 = new UniqueFieldCriteria.Builder() + .setContentType(contentType) + .setField(field) + .setValue(uniqueValue_2) + .setLanguage(language) + .setSite(site) + .setVariantName(VariantAPI.DEFAULT_VARIANT.name()) + .build(); + + final Contentlet contentlet_2 = new ContentletDataGen(contentType) + .setProperty(field.variable(), uniqueValue_2) + .setProperty(IDENTIFIER_KEY, id) + .host(site) + .languageId(language.getId()) + .next(); + + extraTableUniqueFieldValidationStrategy.validate(contentlet_2, field); + + validateDoesNotExists(uniqueFieldCriteria_1); + validateAfterInsert(uniqueFieldCriteria_2, contentlet_2); + } + + private static void validateDoesNotExists(final UniqueFieldCriteria uniqueFieldCriteria_1) throws DotDataException { + final List> results = new DotConnect() + .setSQL("SELECT * FROM unique_fields WHERE supporting_values->>'contentTypeID' = ? AND " + + "supporting_values->>'fieldVariableName' = ? AND supporting_values->>'fieldValue' = ? AND " + + "(supporting_values->>'languageId')::numeric = ?") + .addParam(uniqueFieldCriteria_1.contentType().id()) + .addParam(uniqueFieldCriteria_1.field().variable()) + .addParam(uniqueFieldCriteria_1.value()) + .addParam(uniqueFieldCriteria_1.language().getId()) + .loadObjectResults(); + + assertTrue(results.isEmpty()); + } + + /** + * Method to test: {@link DBUniqueFieldValidationStrategy#validate(Contentlet, Field)} + * When: Called the method twice with different Language + * Should: Insert a register in the unique_fields table + */ + @Test + public void insertWithDifferentLanguage() throws DotDataException, UniqueFieldValueDuplicatedException, DotSecurityException { + final Field field = new FieldDataGen().type(TextField.class).unique(true) + .velocityVarName("field1" + System.currentTimeMillis()).next(); + final ContentType contentType = new ContentTypeDataGen().field(field).nextPersisted(); + final Language language = new LanguageDataGen().nextPersisted(); + final Host site = new SiteDataGen().nextPersisted(); + final Language otherLanguage = new LanguageDataGen().nextPersisted(); + + final String uniqueValue = "UniqueValue1" + System.currentTimeMillis(); + final String id = UUIDGenerator.generateUuid(); + + final Contentlet contentlet = new ContentletDataGen(contentType) + .setProperty(field.variable(), uniqueValue) + .setProperty(IDENTIFIER_KEY, id) + .setProperty(INODE_KEY, UUIDGenerator.generateUuid()) + .host(site) + .languageId(language.getId()) + .next(); + + final UniqueFieldCriteria uniqueFieldCriteria_1 = new UniqueFieldCriteria.Builder() + .setContentType(contentType) + .setField(field) + .setValue(uniqueValue) + .setLanguage(language) + .setSite(site) + .setVariantName(VariantAPI.DEFAULT_VARIANT.name()) + .build(); + + final DBUniqueFieldValidationStrategy extraTableUniqueFieldValidationStrategy = + new DBUniqueFieldValidationStrategy(uniqueFieldDataBaseUtil); + extraTableUniqueFieldValidationStrategy.validate(contentlet, field); + + + final UniqueFieldCriteria uniqueFieldCriteria_2 = new UniqueFieldCriteria.Builder() + .setContentType(contentType) + .setField(field) + .setValue(uniqueValue) + .setLanguage(otherLanguage) + .setSite(site) + .setVariantName(VariantAPI.DEFAULT_VARIANT.name()) + .build(); + + final Contentlet contentlet_2 = new ContentletDataGen(contentType) + .setProperty(field.variable(), uniqueValue) + .setProperty(IDENTIFIER_KEY, id) + .setProperty(INODE_KEY, UUIDGenerator.generateUuid()) + .host(site) + .languageId(otherLanguage.getId()) + .next(); + + + extraTableUniqueFieldValidationStrategy.validate(contentlet_2, field); + + validateDoesNotExists(uniqueFieldCriteria_1); + validateAfterInsert(uniqueFieldCriteria_2, contentlet_2); + } + + /** + * Method to test: {@link DBUniqueFieldValidationStrategy#validate(Contentlet, Field)} + * When: Pretend that we are calling the afterSaved method after saved a new Contentlet + * Should: Update the unique_fields register created before to add the Id in the contentlet list + */ + @Test + public void afterSaved() throws DotDataException, UniqueFieldValueDuplicatedException, DotSecurityException, IOException { + final Field field = new FieldDataGen().type(TextField.class).unique(true).next(); + final ContentType contentType = new ContentTypeDataGen().field(field).nextPersisted(); + final Object value = new RandomString().nextString(); + final Language language = new LanguageDataGen().nextPersisted(); + final Host site = new SiteDataGen().nextPersisted(); + + final Contentlet contentlet = new ContentletDataGen(contentType) + .setProperty(field.variable(), value) + .host(site) + .languageId(language.getId()) + .next(); + + final DBUniqueFieldValidationStrategy extraTableUniqueFieldValidationStrategy = + new DBUniqueFieldValidationStrategy(uniqueFieldDataBaseUtil); + extraTableUniqueFieldValidationStrategy.validate(contentlet, field); + + final UniqueFieldCriteria uniqueFieldCriteria = new UniqueFieldCriteria.Builder() + .setContentType(contentType) + .setField(field) + .setValue(value) + .setLanguage(language) + .setSite(site) + .setVariantName(VariantAPI.DEFAULT_VARIANT.name()) + .build(); + + checkContentIds(uniqueFieldCriteria, list(StringPool.BLANK)); + + final Contentlet contentletSaved = new ContentletDataGen(contentType) + .setProperty(field.variable(), value) + .setProperty(IDENTIFIER_KEY, UUIDGenerator.generateUuid()) + .setProperty(INODE_KEY, UUIDGenerator.generateUuid()) + .host(site) + .languageId(language.getId()) + .next(); + + extraTableUniqueFieldValidationStrategy.afterSaved(contentletSaved, true); + + checkContentIds(uniqueFieldCriteria, list(contentletSaved.getIdentifier())); + } + + private static void checkContentIds(final UniqueFieldCriteria uniqueFieldCriteria, + final Collection compareWith) throws DotDataException, IOException { + final List> results = new DotConnect().setSQL("SELECT * FROM unique_fields WHERE unique_key_val = ?") + .addParam(uniqueFieldCriteria.hash()) + .loadObjectResults(); + + assertEquals(1, results.size()); + + final Map supportingValues = JsonUtil.getJsonFromString( + results.get(0).get("supporting_values").toString()); + + assertEquals(compareWith, supportingValues.get("contentletsId")); + } + + /** + * Method to test: {@link DBUniqueFieldValidationStrategy#afterSaved(Contentlet, boolean)} + * When: Pretend that we are calling the afterSaved method after updated Contentlet + * Should: Update the unique_fields register created before to add the Id in the contentlet list + */ + @Test + public void afterUpdated() throws DotDataException, UniqueFieldValueDuplicatedException, DotSecurityException, IOException { + final Field field = new FieldDataGen().type(TextField.class).unique(true).next(); + final ContentType contentType = new ContentTypeDataGen().field(field).nextPersisted(); + final Object value = new RandomString().nextString(); + final Language language = new LanguageDataGen().nextPersisted(); + final Host site = new SiteDataGen().nextPersisted(); + + final String contentletId = UUIDGenerator.generateUuid(); + + final Contentlet contentlet = new ContentletDataGen(contentType) + .setProperty(field.variable(), value) + .setProperty(IDENTIFIER_KEY, contentletId) + .setProperty(INODE_KEY, UUIDGenerator.generateUuid()) + .host(site) + .languageId(language.getId()) + .next(); + + final DBUniqueFieldValidationStrategy extraTableUniqueFieldValidationStrategy = + new DBUniqueFieldValidationStrategy(uniqueFieldDataBaseUtil); + extraTableUniqueFieldValidationStrategy.validate(contentlet, field); + + final UniqueFieldCriteria uniqueFieldCriteria = new UniqueFieldCriteria.Builder() + .setContentType(contentType) + .setField(field) + .setValue(value) + .setLanguage(language) + .setSite(site) + .setVariantName(VariantAPI.DEFAULT_VARIANT.name()) + .build(); + + checkContentIds(uniqueFieldCriteria, list(contentlet.getIdentifier())); + + final Contentlet contentletSaved = new ContentletDataGen(contentType) + .setProperty(field.variable(), value) + .setProperty(IDENTIFIER_KEY, contentletId) + .setProperty(INODE_KEY, UUIDGenerator.generateUuid()) + .host(site) + .languageId(language.getId()) + .next(); + + extraTableUniqueFieldValidationStrategy.afterSaved(contentletSaved, false); + + checkContentIds(uniqueFieldCriteria, list(contentletSaved.getIdentifier())); + } + + + /** + * Method to test: {@link DBUniqueFieldValidationStrategy#afterSaved(Contentlet, boolean)} + * When: Pretend that we are calling the afterSaved method after saved a new Contentlet with 2 unique fields + * Should: Update the unique_fields register created before to add the Id in the contentlet list + */ + @Test + public void savingWithContentTypeWithMoreThanOneUniqueField() + throws DotDataException, UniqueFieldValueDuplicatedException, DotSecurityException, IOException { + final Field uniquefield_1 = new FieldDataGen().type(TextField.class).unique(true) + .velocityVarName("field1" + System.currentTimeMillis()).next(); + final Field uniquefield_2 = new FieldDataGen().type(TextField.class).unique(true) + .velocityVarName("field2" + System.currentTimeMillis()).next(); + + final ContentType contentType = new ContentTypeDataGen().fields(list(uniquefield_1, uniquefield_2)).nextPersisted(); + final Object value_1 = new RandomString().nextString(); + final Object value_2 = new RandomString().nextString(); + final Language language = new LanguageDataGen().nextPersisted(); + final Host site = new SiteDataGen().nextPersisted(); + + final Contentlet contentlet = new ContentletDataGen(contentType) + .setProperty(uniquefield_1.variable(), value_1) + .setProperty(uniquefield_2.variable(), value_2) + .host(site) + .languageId(language.getId()) + .next(); + + final DBUniqueFieldValidationStrategy extraTableUniqueFieldValidationStrategy = + new DBUniqueFieldValidationStrategy(uniqueFieldDataBaseUtil); + extraTableUniqueFieldValidationStrategy.validate(contentlet, uniquefield_1); + extraTableUniqueFieldValidationStrategy.validate(contentlet, uniquefield_2); + + final UniqueFieldCriteria uniqueFieldCriteria_1 = new UniqueFieldCriteria.Builder() + .setContentType(contentType) + .setField(uniquefield_1) + .setValue(value_1) + .setLanguage(language) + .setSite(site) + .setVariantName(VariantAPI.DEFAULT_VARIANT.name()) + .build(); + + checkContentIds(uniqueFieldCriteria_1, list(StringPool.BLANK)); + + final UniqueFieldCriteria uniqueFieldCriteria_2 = new UniqueFieldCriteria.Builder() + .setContentType(contentType) + .setField(uniquefield_2) + .setValue(value_2) + .setLanguage(language) + .setSite(site) + .setVariantName(VariantAPI.DEFAULT_VARIANT.name()) + .build(); + + checkContentIds(uniqueFieldCriteria_2, list(StringPool.BLANK)); + + final Contentlet contentletSaved = new ContentletDataGen(contentType) + .setProperty(uniquefield_1.variable(), value_1) + .setProperty(uniquefield_2.variable(), value_2) + .setProperty(IDENTIFIER_KEY, UUIDGenerator.generateUuid()) + .setProperty(INODE_KEY, UUIDGenerator.generateUuid()) + .host(site) + .languageId(language.getId()) + .next(); + + extraTableUniqueFieldValidationStrategy.afterSaved(contentletSaved, true); + + checkContentIds(uniqueFieldCriteria_1, list(contentletSaved.getIdentifier())); + checkContentIds(uniqueFieldCriteria_2, list(contentletSaved.getIdentifier())); + } + + + /** + * Method to test: {@link DBUniqueFieldValidationStrategy#afterSaved(Contentlet, boolean)} + * When: Pretend that we are calling the afterSaved method after update a Contentlet with 2 unique fields + * Should: Update the unique_fields register created before to add the Id in the contentlet list + */ + @Test + public void updatingWithContentTypeWithMoreThanOneUniqueField() + throws DotDataException, UniqueFieldValueDuplicatedException, DotSecurityException, IOException { + final Field uniquefield_1 = new FieldDataGen().type(TextField.class).unique(true) + .velocityVarName("field1" + System.currentTimeMillis()).next(); + final Field uniquefield_2 = new FieldDataGen().type(TextField.class).unique(true) + .velocityVarName("field2" + System.currentTimeMillis()).next(); + + final ContentType contentType = new ContentTypeDataGen().fields(list(uniquefield_1, uniquefield_2)).nextPersisted(); + final Object value_1 = new RandomString().nextString(); + final Object value_2 = new RandomString().nextString(); + final Language language = new LanguageDataGen().nextPersisted(); + final Host site = new SiteDataGen().nextPersisted(); + + final String contentletId = UUIDGenerator.generateUuid(); + + final Contentlet contentlet = new ContentletDataGen(contentType) + .setProperty(uniquefield_1.variable(), value_1) + .setProperty(uniquefield_2.variable(), value_2) + .setProperty(IDENTIFIER_KEY, contentletId) + .setProperty(INODE_KEY, UUIDGenerator.generateUuid()) + .host(site) + .languageId(language.getId()) + .next(); + + final DBUniqueFieldValidationStrategy extraTableUniqueFieldValidationStrategy = + new DBUniqueFieldValidationStrategy(uniqueFieldDataBaseUtil); + extraTableUniqueFieldValidationStrategy.validate(contentlet, uniquefield_1); + extraTableUniqueFieldValidationStrategy.validate(contentlet, uniquefield_2); + + final UniqueFieldCriteria uniqueFieldCriteria_1 = new UniqueFieldCriteria.Builder() + .setContentType(contentType) + .setField(uniquefield_1) + .setValue(value_1) + .setLanguage(language) + .setSite(site) + .setVariantName(VariantAPI.DEFAULT_VARIANT.name()) + .build(); + + checkContentIds(uniqueFieldCriteria_1, list(contentlet.getIdentifier())); + + final UniqueFieldCriteria uniqueFieldCriteria_2 = new UniqueFieldCriteria.Builder() + .setContentType(contentType) + .setField(uniquefield_2) + .setValue(value_2) + .setLanguage(language) + .setSite(site) + .setVariantName(VariantAPI.DEFAULT_VARIANT.name()) + .build(); + + checkContentIds(uniqueFieldCriteria_2, list(contentlet.getIdentifier())); + + final Contentlet contentletSaved = new ContentletDataGen(contentType) + .setProperty(uniquefield_1.variable(), value_1) + .setProperty(uniquefield_2.variable(), value_2) + .setProperty(IDENTIFIER_KEY, contentletId) + .setProperty(INODE_KEY, UUIDGenerator.generateUuid()) + .host(site) + .languageId(language.getId()) + .next(); + + extraTableUniqueFieldValidationStrategy.afterSaved(contentletSaved, false); + + checkContentIds(uniqueFieldCriteria_1, list(contentletSaved.getIdentifier())); + checkContentIds(uniqueFieldCriteria_2, list(contentletSaved.getIdentifier())); + } + + /** + * Method to test: {@link DBUniqueFieldValidationStrategy#validate(Contentlet, Field)} + * When: Pretend that we are calling the validate method after updated Contentlet, and the unique value is the changed + * Should: Update the unique_fields register + */ + @Test + public void validateUpdating() throws DotDataException, UniqueFieldValueDuplicatedException, DotSecurityException, IOException { + final Field field = new FieldDataGen().type(TextField.class).unique(true).next(); + final ContentType contentType = new ContentTypeDataGen().field(field).nextPersisted(); + final Object value_1 = new RandomString().nextString(); + final Object value_2 = new RandomString().nextString(); + final Language language = new LanguageDataGen().nextPersisted(); + final Host site = new SiteDataGen().nextPersisted(); + + final String contentletId = UUIDGenerator.generateUuid(); + + final Contentlet contentlet = new ContentletDataGen(contentType) + .setProperty(field.variable(), value_1) + .setProperty(IDENTIFIER_KEY, contentletId) + .setProperty(INODE_KEY, UUIDGenerator.generateUuid()) + .host(site) + .languageId(language.getId()) + .next(); + + final DBUniqueFieldValidationStrategy extraTableUniqueFieldValidationStrategy = + new DBUniqueFieldValidationStrategy(uniqueFieldDataBaseUtil); + extraTableUniqueFieldValidationStrategy.validate(contentlet, field); + + final UniqueFieldCriteria uniqueFieldCriteria_1 = new UniqueFieldCriteria.Builder() + .setContentType(contentType) + .setField(field) + .setValue(value_1) + .setLanguage(language) + .setSite(site) + .setVariantName(VariantAPI.DEFAULT_VARIANT.name()) + .build(); + + checkContentIds(uniqueFieldCriteria_1, list(contentlet.getIdentifier())); + + final Contentlet contentletSaved = new ContentletDataGen(contentType) + .setProperty(field.variable(), value_2) + .setProperty(IDENTIFIER_KEY, contentletId) + .setProperty(INODE_KEY, UUIDGenerator.generateUuid()) + .host(site) + .languageId(language.getId()) + .next(); + + extraTableUniqueFieldValidationStrategy.validate(contentletSaved, field); + + final UniqueFieldCriteria uniqueFieldCriteria_2 = new UniqueFieldCriteria.Builder() + .setContentType(contentType) + .setField(field) + .setValue(value_2) + .setLanguage(language) + .setSite(site) + .setVariantName(VariantAPI.DEFAULT_VARIANT.name()) + .build(); + + checkContentIds(uniqueFieldCriteria_2, list(contentletSaved.getIdentifier())); + + List> results = new DotConnect().setSQL("SELECT * FROM unique_fields WHERE supporting_values->>'contentTypeID' = ?") + .addParam(contentType.id()) + .loadObjectResults(); + + assertEquals(1, results.size()); + } +} diff --git a/dotcms-integration/src/test/java/com/dotcms/contenttype/business/uniquefields/extratable/UniqueFieldDataBaseUtilTest.java b/dotcms-integration/src/test/java/com/dotcms/contenttype/business/uniquefields/extratable/UniqueFieldDataBaseUtilTest.java new file mode 100644 index 000000000000..e7f34a9f5097 --- /dev/null +++ b/dotcms-integration/src/test/java/com/dotcms/contenttype/business/uniquefields/extratable/UniqueFieldDataBaseUtilTest.java @@ -0,0 +1,117 @@ +package com.dotcms.contenttype.business.uniquefields.extratable; + +import com.dotcms.util.CollectionsUtils; +import com.dotcms.util.JsonUtil; +import com.dotmarketing.business.FactoryLocator; +import com.dotmarketing.common.db.DotConnect; +import com.dotmarketing.exception.DotDataException; +import com.dotmarketing.util.StringUtils; +import net.bytebuddy.utility.RandomString; +import org.junit.BeforeClass; +import org.junit.Test; + +import java.io.IOException; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +import static org.junit.Assert.*; + +public class UniqueFieldDataBaseUtilTest { + + @BeforeClass + //TODO: Remove this when the whole change is done + public static void init (){ + try { + new DotConnect().setSQL("CREATE TABLE IF NOT EXISTS unique_fields (" + + "unique_key_val VARCHAR(64) PRIMARY KEY," + + "supporting_values JSONB" + + " )").loadObjectResults(); + } catch (DotDataException e) { + throw new RuntimeException(e); + } + } + + /** + * Method to test: {@link UniqueFieldDataBaseUtil#insert(String, Map)} + * When: Called the method with the right parameters + * Should: Insert a register in the unique_fields table + */ + @Test + public void insert() throws DotDataException, IOException { + final RandomString randomStringGenerator = new RandomString(); + + final String hash = StringUtils.hashText("This is a test " + System.currentTimeMillis()); + + final Map supportingValues = Map.of( + "contentTypeID", randomStringGenerator.nextString(), + "fieldVariableName", randomStringGenerator.nextString(), + "fieldValue", randomStringGenerator.nextString(), + "languageId", randomStringGenerator.nextString(), + "hostId", randomStringGenerator.nextString(), + "uniquePerSite", true, + "contentletsId", CollectionsUtils.list( randomStringGenerator.nextString() ) + ); + + final UniqueFieldDataBaseUtil uniqueFieldDataBaseUtil = new UniqueFieldDataBaseUtil(); + + uniqueFieldDataBaseUtil.insert(hash, supportingValues); + + final List> results = new DotConnect().setSQL("SELECT * FROM unique_fields WHERE unique_key_val = ?") + .addParam(hash).loadObjectResults(); + + assertFalse(results.isEmpty()); + + final List> hashResults = results.stream() + .filter(result -> result.get("unique_key_val").equals(hash)) + .collect(Collectors.toList()); + + assertEquals(1, hashResults.size()); + assertEquals(supportingValues, JsonUtil.getJsonFromString(hashResults.get(0).get("supporting_values").toString())); + } + + /** + * Method to test: {@link UniqueFieldDataBaseUtil#insert(String, Map)} + * When: Called the method with a 'unique_key_val' duplicated + * Should: Throw a {@link java.sql.SQLException} + */ + @Test + public void tryToInsertDuplicated() throws DotDataException { + final RandomString randomStringGenerator = new RandomString(); + + final String hash = StringUtils.hashText("This is a test " + System.currentTimeMillis()); + + final Map supportingValues_1 = Map.of( + "contentTypeID", randomStringGenerator.nextString(), + "fieldVariableName", randomStringGenerator.nextString(), + "fieldValue", randomStringGenerator.nextString(), + "languageId", randomStringGenerator.nextString(), + "hostId", randomStringGenerator.nextString(), + "uniquePerSite", true, + "contentletsId", "['" + randomStringGenerator.nextString() + "']" + ); + + final UniqueFieldDataBaseUtil uniqueFieldDataBaseUtil = new UniqueFieldDataBaseUtil(); + uniqueFieldDataBaseUtil.insert(hash, supportingValues_1); + + final Map supportingValues_2 = Map.of( + "contentTypeID", randomStringGenerator.nextString(), + "fieldVariableName", randomStringGenerator.nextString(), + "fieldValue", randomStringGenerator.nextString(), + "languageId", randomStringGenerator.nextString(), + "hostId", randomStringGenerator.nextString(), + "uniquePerSite", true, + "contentletsId", CollectionsUtils.list( randomStringGenerator.nextString()) + ); + + try { + uniqueFieldDataBaseUtil.insert(hash, supportingValues_2); + + throw new AssertionError("Exception expected"); + } catch (DotDataException e) { + assertTrue(e.getMessage().startsWith("ERROR: duplicate key value violates unique constraint \"unique_fields_pkey\"")); + } + } + + +}