From 6181061a198ab67e70e9c8cbebe2874dbf9a9f14 Mon Sep 17 00:00:00 2001 From: freddyDOTCMS <147462678+freddyDOTCMS@users.noreply.github.com> Date: Fri, 6 Dec 2024 18:29:03 -0600 Subject: [PATCH] #30285 Fixing several unique fields database validation bugs (#30872) I am fixing a couple of bugs here: - The unique_fields table is not getting clean up after deleting a ContentType - When try to update a Publish Contentlet with a unique fields but the unique fields is not changed ### Proposed Changes * Create a method to listen when a ContenType is deleted to clean up the unique_fields table https://github.com/dotCMS/core/pull/30872/files#diff-217758731836e15813d5ad2e85a398e4d52317cbbb9334d5961895f9d7883851R113 * Used the ContentTypeDeletedEvent to include the whole ContentType and not just the variable name, we are going to need the field list to know if this ContentType has unique fields later https://github.com/dotCMS/core/pull/30872/files#diff-b3de838f3681e084b3121056ed3a3002a19761e286f17a26ba8f466842b345a9R7 * The Unique value can be used by a LIVE or WORKING version, If the value for the LIVE and WORKING version are different then you are going to have a register for each but if the value is the same then you are going to have just 1 register for both with the LIVE equals true, so we when we clean uo the table after a update we need to check if the register that already exists are LIVE or WORKING https://github.com/dotCMS/core/pull/30872/files#diff-217758731836e15813d5ad2e85a398e4d52317cbbb9334d5961895f9d7883851R113-R137 --------- Co-authored-by: fabrizzio-dotCMS --- .../business/ContentTypeAPIImpl.java | 4 +- .../business/ContentTypeDestroyAPIImpl.java | 2 +- .../UniqueFieldValidationStrategy.java | 11 + .../UniqueFieldsValidationInitializer.java | 4 +- .../DBUniqueFieldValidationStrategy.java | 45 +- .../extratable/UniqueFieldDataBaseUtil.java | 44 +- .../extratable/UniqueFieldsTableCleaner.java | 28 +- .../model/event/ContentTypeDeletedEvent.java | 14 +- .../business/ESContentletAPIImplTest.java | 389 +++++++++++++++++- 9 files changed, 506 insertions(+), 35 deletions(-) diff --git a/dotCMS/src/main/java/com/dotcms/contenttype/business/ContentTypeAPIImpl.java b/dotCMS/src/main/java/com/dotcms/contenttype/business/ContentTypeAPIImpl.java index 4f183cfd7d0d..adec005bc5d7 100644 --- a/dotCMS/src/main/java/com/dotcms/contenttype/business/ContentTypeAPIImpl.java +++ b/dotCMS/src/main/java/com/dotcms/contenttype/business/ContentTypeAPIImpl.java @@ -191,7 +191,7 @@ private void transactionalDelete(ContentType type) throws DotDataException { Logger.error(ContentType.class, e.getMessage(), e); throw new BaseRuntimeInternationalizationException(e); } - HibernateUtil.addCommitListener(() -> localSystemEventsAPI.notify(new ContentTypeDeletedEvent(type.variable()))); + HibernateUtil.addCommitListener(() -> localSystemEventsAPI.notify(new ContentTypeDeletedEvent(type))); } /** @@ -341,7 +341,7 @@ private void disposeSourceThenFireContentDelete( final ContentType source, final HibernateUtil.addCommitListener(() -> { //Notify the system events API that the content type has been deleted, so it can take care of the WF clean up - localSystemEventsAPI.notify(new ContentTypeDeletedEvent(source.variable())); + localSystemEventsAPI.notify(new ContentTypeDeletedEvent(source)); //By default, the deletion process takes placed within job Logger.info(this, String.format(" Content type (%s) will be deleted asynchronously using Quartz Job.", source.name())); if(asyncDeleteWithJob) { diff --git a/dotCMS/src/main/java/com/dotcms/contenttype/business/ContentTypeDestroyAPIImpl.java b/dotCMS/src/main/java/com/dotcms/contenttype/business/ContentTypeDestroyAPIImpl.java index e24a2b1e1d1c..70d1de307a0a 100644 --- a/dotCMS/src/main/java/com/dotcms/contenttype/business/ContentTypeDestroyAPIImpl.java +++ b/dotCMS/src/main/java/com/dotcms/contenttype/business/ContentTypeDestroyAPIImpl.java @@ -328,7 +328,7 @@ void broadcastEvents(final ContentType type, final User user) { throw new BaseRuntimeInternationalizationException(e); } final LocalSystemEventsAPI localSystemEventsAPI = APILocator.getLocalSystemEventsAPI(); - localSystemEventsAPI.notify(new ContentTypeDeletedEvent(type.variable())); + localSystemEventsAPI.notify(new ContentTypeDeletedEvent(type)); notifyContentTypeDestroyed(type); } 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 index 2dbead470238..737b9828f795 100644 --- a/dotCMS/src/main/java/com/dotcms/contenttype/business/uniquefields/UniqueFieldValidationStrategy.java +++ b/dotCMS/src/main/java/com/dotcms/contenttype/business/uniquefields/UniqueFieldValidationStrategy.java @@ -162,4 +162,15 @@ default void cleanUp(final Field field) throws DotDataException { //Default implementation do nothing } + /** + * Method called after delete a {@link ContentType}, to allow the {@link UniqueFieldValidationStrategy} do any extra + * work that it need it. + * + * @param contentType deleted ContentType + * @throws DotDataException + */ + default void cleanUp(final ContentType contentType) throws DotDataException { + //Default implementation do nothing + } + } diff --git a/dotCMS/src/main/java/com/dotcms/contenttype/business/uniquefields/UniqueFieldsValidationInitializer.java b/dotCMS/src/main/java/com/dotcms/contenttype/business/uniquefields/UniqueFieldsValidationInitializer.java index 6af33150a40c..336af438cbbd 100644 --- a/dotCMS/src/main/java/com/dotcms/contenttype/business/uniquefields/UniqueFieldsValidationInitializer.java +++ b/dotCMS/src/main/java/com/dotcms/contenttype/business/uniquefields/UniqueFieldsValidationInitializer.java @@ -26,8 +26,8 @@ @Dependent public class UniqueFieldsValidationInitializer implements DotInitializer { - private UniqueFieldDataBaseUtil uniqueFieldDataBaseUtil; - private DotDatabaseMetaData dotDatabaseMetaData; + private final UniqueFieldDataBaseUtil uniqueFieldDataBaseUtil; + private final DotDatabaseMetaData dotDatabaseMetaData; @Inject public UniqueFieldsValidationInitializer(final UniqueFieldDataBaseUtil uniqueFieldDataBaseUtil){ 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 index cf37368e0f96..85986032a79b 100644 --- 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 @@ -33,8 +33,7 @@ import java.util.stream.Collectors; import static com.dotcms.content.elasticsearch.business.ESContentletAPIImpl.UNIQUE_PER_SITE_FIELD_VARIABLE_NAME; -import static com.dotcms.contenttype.business.uniquefields.extratable.UniqueFieldCriteria.CONTENTLET_IDS_ATTR; -import static com.dotcms.contenttype.business.uniquefields.extratable.UniqueFieldCriteria.UNIQUE_PER_SITE_ATTR; +import static com.dotcms.contenttype.business.uniquefields.extratable.UniqueFieldCriteria.*; import static com.dotmarketing.util.Constants.DONT_RESPECT_FRONT_END_ROLES; /** @@ -111,10 +110,40 @@ private static boolean isContentletBeingUpdated(final Contentlet contentlet) { */ @SuppressWarnings("unchecked") private void cleanUniqueFieldsUp(final Contentlet contentlet, final Field field) throws DotDataException { - Optional> uniqueFieldOptional = uniqueFieldDataBaseUtil.get(contentlet, field); + List> uniqueFields = uniqueFieldDataBaseUtil.get(contentlet, field); + + if (UtilMethods.isSet(uniqueFields)) { + final List> workingUniqueFields = uniqueFields.stream() + .filter(uniqueValue -> Boolean.FALSE.equals(getSupportingValues(uniqueValue).get("live"))) + .collect(Collectors.toList()); + + if (!workingUniqueFields.isEmpty()) { + workingUniqueFields.forEach(uniqueField -> cleanUniqueFieldUp(contentlet.getIdentifier(), uniqueField)); + } else { + uniqueFields.stream() + .filter(uniqueValue -> Boolean.TRUE.equals(getSupportingValues(uniqueValue).get("live"))) + .limit(1) + .findFirst() + .ifPresent(uniqueFieldValue -> { + final Map supportingValues = getSupportingValues(uniqueFieldValue); + final String oldUniqueValue = supportingValues.get(FIELD_VALUE_ATTR).toString(); + final String newUniqueValue = contentlet.getStringProperty(field.variable()); + + if (oldUniqueValue.equals(newUniqueValue)) { + cleanUniqueFieldUp(contentlet.getIdentifier(), uniqueFieldValue); + } + }); + } + - if (uniqueFieldOptional.isPresent()) { - cleanUniqueFieldUp(contentlet.getIdentifier(), uniqueFieldOptional.get()); + } + } + + private static Map getSupportingValues(Map uniqueField) { + try { + return JsonUtil.getJsonFromString(uniqueField.get("supporting_values").toString()); + } catch (IOException e) { + throw new DotRuntimeException(e); } } @@ -271,12 +300,18 @@ public void cleanUp(final Field field) throws DotDataException { uniqueFieldDataBaseUtil.delete(field); } + @Override + public void cleanUp(final ContentType contentType) throws DotDataException { + uniqueFieldDataBaseUtil.delete(contentType); + } + @Override public void afterPublish(final String inode) { try { final Contentlet contentlet = APILocator.getContentletAPI().find(inode, APILocator.systemUser(), false); if (hasUniqueField(contentlet.getContentType())) { + uniqueFieldDataBaseUtil.removeLive(contentlet); uniqueFieldDataBaseUtil.setLive(contentlet, true); } } catch (DotDataException | DotSecurityException e) { 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 index af005f87dc70..c1ef36023d37 100644 --- 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 @@ -5,6 +5,7 @@ import com.dotcms.business.WrapInTransaction; import com.dotcms.contenttype.model.field.Field; +import com.dotcms.contenttype.model.type.ContentType; import com.dotmarketing.common.db.DotConnect; import com.dotmarketing.exception.DotDataException; import com.dotmarketing.exception.DotRuntimeException; @@ -62,7 +63,6 @@ public class UniqueFieldDataBaseUtil { "WHERE supporting_values->'" + CONTENTLET_IDS_ATTR + "' @> ?::jsonb " + "AND supporting_values->>'" + VARIANT_ATTR + "' = ? " + "AND (supporting_values->>'"+ LANGUAGE_ID_ATTR + "')::INTEGER = ? " + - "AND (supporting_values->>'" + LIVE_ATTR + "')::BOOLEAN = ? " + "AND supporting_values->>'" + FIELD_VARIABLE_NAME_ATTR + "' = ?"; private final static String DELETE_UNIQUE_FIELDS_BY_CONTENTLET = "DELETE FROM unique_fields " + @@ -89,6 +89,10 @@ public class UniqueFieldDataBaseUtil { private final static String DELETE_UNIQUE_FIELDS_BY_FIELD = "DELETE FROM unique_fields " + "WHERE supporting_values->>'" + FIELD_VARIABLE_NAME_ATTR + "' = ?"; + private final static String DELETE_UNIQUE_FIELDS_BY_CONTENT_TYPE = "DELETE FROM unique_fields " + + "WHERE supporting_values->>'" + CONTENT_TYPE_ID_ATTR + "' = ?"; + + private final static String POPULATE_UNIQUE_FIELDS_VALUES_QUERY = "INSERT INTO unique_fields (unique_key_val, supporting_values) " + "SELECT encode(sha256(CONCAT(content_type_id, field_var_name, language_id, field_value, " + " CASE WHEN uniquePerSite = 'true' THEN host_id ELSE '' END)::bytea), 'hex') as unique_key_val, " + @@ -208,20 +212,13 @@ public void updateContentListWithHash(final String hash, final List cont * @throws DotDataException If an error occurs when interacting with the database. */ @CloseDBIfOpened - public Optional> get(final Contentlet contentlet, final Field field) throws DotDataException { - try { - final List> results = new DotConnect().setSQL(GET_UNIQUE_FIELDS_BY_CONTENTLET) - .addParam("\"" + contentlet.getIdentifier() + "\"") - .addParam(contentlet.getVariantId()) - .addParam(contentlet.getLanguageId()) - .addParam(contentlet.isLive()) - .addParam(field.variable()) - .loadObjectResults(); - - return results.isEmpty() ? Optional.empty() : Optional.of(results.get(0)); - } catch (DotSecurityException e) { - throw new DotRuntimeException(e); - } + public List> get(final Contentlet contentlet, final Field field) throws DotDataException { + return new DotConnect().setSQL(GET_UNIQUE_FIELDS_BY_CONTENTLET) + .addParam("\"" + contentlet.getIdentifier() + "\"") + .addParam(contentlet.getVariantId()) + .addParam(contentlet.getLanguageId()) + .addParam(field.variable()) + .loadObjectResults(); } /** @@ -275,10 +272,10 @@ private static String getUniqueRecalculationQuery(final boolean uniquePerSite) { } @CloseDBIfOpened - public List> get(final String contentId, final long languegeId) throws DotDataException { + public List> get(final String contentId, final long languageId) throws DotDataException { return new DotConnect().setSQL(GET_UNIQUE_FIELDS_BY_CONTENTLET_AND_LANGUAGE) .addParam("\"" + contentId + "\"") - .addParam(languegeId) + .addParam(languageId) .loadObjectResults(); } @@ -324,6 +321,19 @@ public void delete(final Field field) throws DotDataException { .loadObjectResults(); } + /** + * Delete all the unique values for a {@link ContentType} + * + * @param contentType + * @throws DotDataException + */ + @WrapInTransaction + public void delete(final ContentType contentType) throws DotDataException { + new DotConnect().setSQL(DELETE_UNIQUE_FIELDS_BY_CONTENT_TYPE) + .addParam(contentType.id()) + .loadObjectResults(); + } + /** * Set the supporting_value->live attribute to true to any register with the same Content's id, variant and language * diff --git a/dotCMS/src/main/java/com/dotcms/contenttype/business/uniquefields/extratable/UniqueFieldsTableCleaner.java b/dotCMS/src/main/java/com/dotcms/contenttype/business/uniquefields/extratable/UniqueFieldsTableCleaner.java index 7ce119b4dc0e..40417adef0fe 100644 --- a/dotCMS/src/main/java/com/dotcms/contenttype/business/uniquefields/extratable/UniqueFieldsTableCleaner.java +++ b/dotCMS/src/main/java/com/dotcms/contenttype/business/uniquefields/extratable/UniqueFieldsTableCleaner.java @@ -1,6 +1,7 @@ package com.dotcms.contenttype.business.uniquefields.extratable; import com.dotcms.contenttype.business.uniquefields.UniqueFieldValidationStrategyResolver; +import com.dotcms.contenttype.model.event.ContentTypeDeletedEvent; import com.dotcms.contenttype.model.field.Field; import com.dotcms.contenttype.model.field.event.FieldDeletedEvent; import com.dotcms.contenttype.model.type.ContentType; @@ -68,7 +69,7 @@ public void cleanUpAfterDeleteContentlet(final DeleteContentletVersionInfoEvent final ContentType contentType = APILocator.getContentTypeAPI(APILocator.systemUser()) .find(contentlet.getContentTypeId()); - boolean hasUniqueField = contentType.fields().stream().anyMatch(Field::unique); + boolean hasUniqueField = hasUniqueField(contentType); if (hasUniqueField) { uniqueFieldValidationStrategyResolver.get().cleanUp(contentlet, event.isDeleteAllVariant()); @@ -78,8 +79,12 @@ public void cleanUpAfterDeleteContentlet(final DeleteContentletVersionInfoEvent } } + private static boolean hasUniqueField(ContentType contentType) { + return contentType.fields().stream().anyMatch(Field::unique); + } + /** - * Listen when a Field is deleted and if this ia a Unique Field then delete all the register in + * Listen when a Field is deleted and if this is a Unique Field then delete all the register in * unique_fields table for this Field * * @param event @@ -94,4 +99,23 @@ public void cleanUpAfterDeleteUniqueField(final FieldDeletedEvent event) throws uniqueFieldValidationStrategyResolver.get().cleanUp(deletedField); } } + + /** + * Listen when a {@link ContentType} is deleted and if this has at least one Unique Field then delete all the register in + * unique_fields table for this {@link ContentType} + * + * @param event + * + * @throws DotDataException + */ + @Subscriber + public void cleanUpAfterDeleteContentType(final ContentTypeDeletedEvent contentTypeDeletedEvent) throws DotDataException { + final ContentType contentType = contentTypeDeletedEvent.getContentType(); + + boolean hasUniqueField = hasUniqueField(contentType); + + if (hasUniqueField) { + uniqueFieldValidationStrategyResolver.get().cleanUp(contentType); + } + } } diff --git a/dotCMS/src/main/java/com/dotcms/contenttype/model/event/ContentTypeDeletedEvent.java b/dotCMS/src/main/java/com/dotcms/contenttype/model/event/ContentTypeDeletedEvent.java index 6e7d29fe7841..b7ea89567991 100644 --- a/dotCMS/src/main/java/com/dotcms/contenttype/model/event/ContentTypeDeletedEvent.java +++ b/dotCMS/src/main/java/com/dotcms/contenttype/model/event/ContentTypeDeletedEvent.java @@ -1,14 +1,20 @@ package com.dotcms.contenttype.model.event; +import com.dotcms.contenttype.model.type.ContentType; + public class ContentTypeDeletedEvent { - private final String contentTypeVar; + private final ContentType contentType; - public ContentTypeDeletedEvent(final String contentTypeVar) { - this.contentTypeVar = contentTypeVar; + public ContentTypeDeletedEvent(final ContentType contentType) { + this.contentType = contentType; } public String getContentTypeVar() { - return contentTypeVar; + return contentType.variable(); + } + + public ContentType getContentType() { + return contentType; } } 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 d284aae139c1..dc9bb933b5aa 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 @@ -82,6 +82,7 @@ import com.dotmarketing.portlets.languagesmanager.model.Language; import com.dotmarketing.portlets.structure.model.Relationship; import com.dotmarketing.portlets.templates.model.Template; +import com.dotmarketing.quartz.job.ContentTypeDeleteJob; import com.dotmarketing.util.Logger; import com.dotmarketing.util.StringUtils; import com.dotmarketing.util.UtilMethods; @@ -106,6 +107,8 @@ import org.junit.runner.RunWith; import org.mockito.Mockito; import org.postgresql.util.PGobject; +import org.quartz.JobExecutionContext; +import org.quartz.JobExecutionException; import javax.enterprise.context.ApplicationScoped; import javax.servlet.FilterChain; @@ -4091,6 +4094,387 @@ public void throwUniqueErrorWhenUnPublishWhenWorkingAndLiveVersionAreSame(final } } + /** + * Method to test: {@link ContentletAPI#checkin(Contentlet, User, boolean)} } + * When: + * - Create a unique {@link TextField}, with the {@link ESContentletAPIImpl#UNIQUE_PER_SITE_FIELD_VARIABLE_NAME} + * {@link com.dotcms.contenttype.model.field.FieldVariable} set to false + * - Create a ContentType and add the previous created field to it, also add a title {@link Field} + * - Create a {@link Contentlet} with unique-live as value to the Unique Fields, and publish it + * - Create a new Working version and change just the title + * + * Should: Create the new version without problems and have just one register in the unique_fields table with live equals to false + * @throws DotDataException + * @throws DotSecurityException + */ + @Test + @UseDataProvider("enabledUniqueFieldDatabaseValidation") + public void createWorkingVersionWithUniqueFields(final Boolean enabledDataBaseValidation) throws DotDataException, DotSecurityException, IOException { + final boolean oldEnabledDataBaseValidation = ESContentletAPIImpl.getFeatureFlagDbUniqueFieldValidation(); + + try { + ESContentletAPIImpl.setFeatureFlagDbUniqueFieldValidation(enabledDataBaseValidation); + final ContentType contentType = new ContentTypeDataGen() + .nextPersisted(); + + final Language language = new LanguageDataGen().nextPersisted(); + + final Field uniqueTextField = new FieldDataGen() + .contentTypeId(contentType.id()) + .unique(true) + .type(TextField.class) + .nextPersisted(); + + final Field titleTextField = new FieldDataGen() + .velocityVarName("title") + .contentTypeId(contentType.id()) + .type(TextField.class) + .nextPersisted(); + + final Host host = new SiteDataGen().nextPersisted(); + + final Contentlet liveContentlet = new ContentletDataGen(contentType) + .host(host) + .languageId(language.getId()) + .setProperty(uniqueTextField.variable(), "unique-live") + .setProperty(titleTextField.variable(), "live-title") + .nextPersistedAndPublish(); + + Contentlet workingVersion = ContentletDataGen.checkout(liveContentlet); + workingVersion.setProperty(titleTextField.variable(), "working-title"); + + APILocator.getContentletAPI().checkin(workingVersion, APILocator.systemUser(), false); + + if (enabledDataBaseValidation) { + final List> result = new DotConnect() + .setSQL("SELECT * FROM unique_fields WHERE supporting_values->>'" + CONTENT_TYPE_ID_ATTR + "' = ?") + .addParam(contentType.id()) + .loadObjectResults(); + + assertEquals(1, result.size()); + + final Map supportingValues = JsonUtil.getJsonFromString(result.get(0).get("supporting_values").toString()); + + assertEquals(false, supportingValues.get("live")); + } + } finally { + ESContentletAPIImpl.setFeatureFlagDbUniqueFieldValidation(oldEnabledDataBaseValidation); + } + } + + /** + * Method to test: {@link ContentletAPI#checkin(Contentlet, User, boolean)} } + * When: + * - Create a unique {@link TextField}, with the {@link ESContentletAPIImpl#UNIQUE_PER_SITE_FIELD_VARIABLE_NAME} + * {@link com.dotcms.contenttype.model.field.FieldVariable} set to false + * - Create a ContentType and add the previous created field to it, also add a title {@link Field} + * - Create a {@link Contentlet} with unique-live as value to the Unique Fields, and publish it + * - Create a new Working version and change the unique field value unique-working-1. + * - Create another new Working version and change the unique field value unique-working-2. + * - try to create a new Contentlet with the unique-live value, must fail + * - try to create a new Contentlet with the unique-working-1 value, must work + * - try to create a new Contentlet with the unique-working-2 value, must fail + * + * Should: Create the new version without problems and have just one register in the unique_fields table with live equals to false + * @throws DotDataException + * @throws DotSecurityException + */ + @Test + public void createTwoWorkingVersionWithUniqueFields() throws DotDataException, DotSecurityException, IOException { + final boolean oldEnabledDataBaseValidation = ESContentletAPIImpl.getFeatureFlagDbUniqueFieldValidation(); + + try { + ESContentletAPIImpl.setFeatureFlagDbUniqueFieldValidation(true); + final ContentType contentType = new ContentTypeDataGen() + .nextPersisted(); + + final Language language = new LanguageDataGen().nextPersisted(); + + final Field uniqueTextField = new FieldDataGen() + .contentTypeId(contentType.id()) + .unique(true) + .type(TextField.class) + .nextPersisted(); + + + final Host host = new SiteDataGen().nextPersisted(); + + final Contentlet liveContentlet = new ContentletDataGen(contentType) + .host(host) + .languageId(language.getId()) + .setProperty(uniqueTextField.variable(), "unique-live") + .nextPersistedAndPublish(); + + Contentlet workingVersion1 = ContentletDataGen.checkout(liveContentlet); + workingVersion1.setProperty(uniqueTextField.variable(), "unique-working-1"); + APILocator.getContentletAPI().checkin(workingVersion1, APILocator.systemUser(), false); + + Contentlet workingVersion2 = ContentletDataGen.checkout(liveContentlet); + workingVersion2.setProperty(uniqueTextField.variable(), "unique-working-2"); + APILocator.getContentletAPI().checkin(workingVersion2, APILocator.systemUser(), false); + + tryToCreateContentlet(contentType, host, language, uniqueTextField, "unique-live"); + createContentlet(contentType, host, language, uniqueTextField, "unique-working-1"); + tryToCreateContentlet(contentType, host, language, uniqueTextField, "unique-working-2"); + + final List> results = new DotConnect() + .setSQL("SELECT * FROM unique_fields WHERE supporting_values->'" + CONTENTLET_IDS_ATTR + "'@> ?::jsonb") + .addParam("\"" + liveContentlet.getIdentifier() + "\"") + .loadObjectResults(); + + assertEquals(2, results.size()); + assertEquals(1, results.stream() + .map(this::getSupportingValue) + .filter(supportingValues -> Boolean.TRUE.equals(supportingValues.get("live"))).count()); + + assertEquals(1, results.stream() + .map(this::getSupportingValue) + .filter(supportingValues -> Boolean.FALSE.equals(supportingValues.get("live"))).count()); + + for (final Map result : results) { + final Map supportingValues = JsonUtil.getJsonFromString(result.get("supporting_values").toString()); + + if (Boolean.TRUE.equals(supportingValues.get("live"))) { + assertEquals("unique-live", supportingValues.get("fieldValue")); + } else { + assertEquals("unique-working-2", supportingValues.get("fieldValue")); + } + } + + } finally { + ESContentletAPIImpl.setFeatureFlagDbUniqueFieldValidation(oldEnabledDataBaseValidation); + } + } + + + /** + * Method to test: {@link ContentletAPI#checkin(Contentlet, User, boolean)} } + * When: + * - Enabled the Unique Field Database validation + * - Create a unique {@link TextField} called it 'unique', with the {@link ESContentletAPIImpl#UNIQUE_PER_SITE_FIELD_VARIABLE_NAME} + * {@link com.dotcms.contenttype.model.field.FieldVariable} set to false + * - Create another {@link TextField} called it 'title', with the {@link ESContentletAPIImpl#UNIQUE_PER_SITE_FIELD_VARIABLE_NAME} + * {@link com.dotcms.contenttype.model.field.FieldVariable} set to false + * - Create a ContentType and add the previous created fields to it + * - Create a {@link Contentlet} with unique-value as value to the Unique Fields and 'LIVE_VERSION' as value + * to the title {@link Field}, and publish it + * - Update the value for the title field to WORKING_VERSION and just save the Contentlet (does not publish it) + * - Try to create another {@link Contentlet} with the unique field value equals to 'unique-value' must fail + * - Unpublish the first created {@link Contentlet} + * - Try to create another {@link Contentlet} with the unique field value equals to 'unique-value' must fail again + * + * Should: Have just one register in the unique_fields table with the live attribute set to false + * + * @throws DotDataException + * @throws DotSecurityException + */ + @Test + public void updateContentletWithLiveVersionButNotChangeUniqueFieldValue() throws DotDataException, DotSecurityException { + + final boolean enabledDataBaseValidation = true; + final boolean oldEnabledDataBaseValidation = ESContentletAPIImpl.getFeatureFlagDbUniqueFieldValidation(); + + try { + ESContentletAPIImpl.setFeatureFlagDbUniqueFieldValidation(enabledDataBaseValidation); + final ContentType contentType = new ContentTypeDataGen() + .nextPersisted(); + + final Language language = new LanguageDataGen().nextPersisted(); + + final Field uniqueTextField = new FieldDataGen() + .contentTypeId(contentType.id()) + .unique(true) + .type(TextField.class) + .nextPersisted(); + + final Field titleTextField = new FieldDataGen() + .contentTypeId(contentType.id()) + .velocityVarName("title") + .type(TextField.class) + .nextPersisted(); + + final Host host = new SiteDataGen().nextPersisted(); + + final Contentlet contentlet_1 = new ContentletDataGen(contentType) + .host(host) + .languageId(language.getId()) + .setProperty(uniqueTextField.variable(), "unique-value") + .setProperty(titleTextField.variable(), "LIVE_VERSION") + .nextPersistedAndPublish(); + + Contentlet contentlet_1WorkingVersion = ContentletDataGen.checkout(contentlet_1); + contentlet_1WorkingVersion.setProperty(titleTextField.variable(), "WORKING_VERSION"); + ContentletDataGen.checkin(contentlet_1WorkingVersion); + + final Contentlet contentlet_2 = new ContentletDataGen(contentType) + .host(host) + .languageId(language.getId()) + .setProperty(uniqueTextField.variable(), "unique-value") + .setProperty(titleTextField.variable(), "ANOTHER_CONTENT") + .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' ['ANOTHER_CONTENT'] has invalid/missing field(s)." + + " - Fields: [UNIQUE]: %s (%s)", uniqueTextField.name(), uniqueTextField.variable()); + + assertEquals(expectedMessage, e.getMessage()); + } + + final List> results_1 = new DotConnect() + .setSQL("SELECT * FROM unique_fields WHERE supporting_values->'" + CONTENTLET_IDS_ATTR + "'@> ?::jsonb") + .addParam("\"" + contentlet_1.getIdentifier() + "\"") + .loadObjectResults(); + + assertEquals(1, results_1.size()); + assertEquals(false, Boolean.TRUE.equals(results_1.get(0).get("live"))); + + ContentletDataGen.unpublish(contentlet_1); + + 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' ['ANOTHER_CONTENT'] has invalid/missing field(s)." + + " - Fields: [UNIQUE]: %s (%s)", uniqueTextField.name(), uniqueTextField.variable()); + + assertEquals(expectedMessage, e.getMessage()); + } + + final List> results_2 = new DotConnect() + .setSQL("SELECT * FROM unique_fields WHERE supporting_values->'" + CONTENTLET_IDS_ATTR + "'@> ?::jsonb") + .addParam("\"" + contentlet_1.getIdentifier() + "\"") + .loadObjectResults(); + + assertEquals(1, results_2.size()); + assertEquals(false, Boolean.FALSE.equals(results_2.get(0).get("live"))); + } finally { + ESContentletAPIImpl.setFeatureFlagDbUniqueFieldValidation(oldEnabledDataBaseValidation); + } + } + + private Map getSupportingValue(Map result) { + try { + return JsonUtil.getJsonFromString(result.get("supporting_values").toString()); + } catch (IOException e) { + throw new DotRuntimeException(e); + } + } + + /** + * Method to test: {@link ContentletAPI#checkin(Contentlet, User, boolean)} } + * When: + * - Create a unique {@link TextField}, with the {@link ESContentletAPIImpl#UNIQUE_PER_SITE_FIELD_VARIABLE_NAME} + * {@link com.dotcms.contenttype.model.field.FieldVariable} set to false + * - Create a ContentType and add the previous created field to it, also add a title {@link Field} + * - Create a {@link Contentlet} with unique-live as value to the Unique Fields, and publish it + * - Create a new Working version and change the unique field value unique-working-1. + * - Create another new Working version and change the unique field value unique-working-2. + * - try to create a new Contentlet with the unique-live value, must fail + * - try to create a new Contentlet with the unique-working-1 value, must work + * - try to create a new Contentlet with the unique-working-2 value, must fail + * - Public the last Working version + * - try to create a new Contentlet with the unique-live value, must work + * + * Should: Create the new version without problems and have just one register in the unique_fields table with live equals to false + * @throws DotDataException + * @throws DotSecurityException + */ + @Test + public void publishAfterCreateTwoWorkingVersionWithUniqueFields() throws DotDataException, DotSecurityException { + final boolean oldEnabledDataBaseValidation = ESContentletAPIImpl.getFeatureFlagDbUniqueFieldValidation(); + + try { + ESContentletAPIImpl.setFeatureFlagDbUniqueFieldValidation(true); + final ContentType contentType = new ContentTypeDataGen() + .nextPersisted(); + + final Language language = new LanguageDataGen().nextPersisted(); + + final Field uniqueTextField = new FieldDataGen() + .contentTypeId(contentType.id()) + .unique(true) + .type(TextField.class) + .nextPersisted(); + + + final Host host = new SiteDataGen().nextPersisted(); + + final Contentlet liveContentlet = new ContentletDataGen(contentType) + .host(host) + .languageId(language.getId()) + .setProperty(uniqueTextField.variable(), "unique-live") + .nextPersistedAndPublish(); + + Contentlet workingVersion1 = ContentletDataGen.checkout(liveContentlet); + workingVersion1.setProperty(uniqueTextField.variable(), "unique-working-1"); + APILocator.getContentletAPI().checkin(workingVersion1, APILocator.systemUser(), false); + + Contentlet workingVersion2 = ContentletDataGen.checkout(liveContentlet); + workingVersion2.setProperty(uniqueTextField.variable(), "unique-working-2"); + APILocator.getContentletAPI().checkin(workingVersion2, APILocator.systemUser(), false); + + tryToCreateContentlet(contentType, host, language, uniqueTextField, "unique-live"); + createContentlet(contentType, host, language, uniqueTextField, "unique-working-1"); + tryToCreateContentlet(contentType, host, language, uniqueTextField, "unique-working-2"); + + ContentletDataGen.publish(workingVersion2); + + createContentlet(contentType, host, language, uniqueTextField, "unique-live"); + tryToCreateContentlet(contentType, host, language, uniqueTextField, "unique-working-2"); + + final List> results = new DotConnect() + .setSQL("SELECT * FROM unique_fields WHERE supporting_values->'" + CONTENTLET_IDS_ATTR + "'@> ?::jsonb") + .addParam("\"" + liveContentlet.getIdentifier() + "\"") + .loadObjectResults(); + + assertEquals(1, results.size()); + + final Map supportingValues = getSupportingValue (results.get(0)); + + assertEquals(true, supportingValues.get("live")); + assertEquals("unique-working-2", supportingValues.get("fieldValue")); + } finally { + ESContentletAPIImpl.setFeatureFlagDbUniqueFieldValidation(oldEnabledDataBaseValidation); + } + } + + private static void createContentlet(final ContentType contentType, final Host host, + final Language language, final Field field, final String value) + throws DotDataException, DotSecurityException { + + final Contentlet contentlet = new ContentletDataGen(contentType) + .host(host) + .languageId(language.getId()) + .setProperty(field.variable(), value) + .next(); + + APILocator.getContentletAPI().checkin(contentlet, APILocator.systemUser(), false); + } + + private static void tryToCreateContentlet(final ContentType contentType, final Host host, + final Language language, final Field field, final String value) + throws DotDataException, DotSecurityException { + + final Contentlet contentlet = new ContentletDataGen(contentType) + .host(host) + .languageId(language.getId()) + .setProperty(field.variable(), value) + .next(); + + try { + APILocator.getContentletAPI().checkin(contentlet, 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)", field.name(), field.variable()); + + assertEquals(expectedMessage, e.getMessage()); + } + } + /** * Method to test: {@link ContentletAPI#checkin(Contentlet, User, boolean)} } * When: @@ -4106,7 +4490,7 @@ public void throwUniqueErrorWhenUnPublishWhenWorkingAndLiveVersionAreSame(final * @throws DotSecurityException */ @Test - public void cleanUpExtraTableAfterDeleteContentType() throws DotDataException, DotSecurityException { + public void cleanUpExtraTableAfterDeleteContentType() throws DotDataException, DotSecurityException, JobExecutionException { final boolean oldEnabledDataBaseValidation = ESContentletAPIImpl.getFeatureFlagDbUniqueFieldValidation(); try { @@ -4132,7 +4516,8 @@ public void cleanUpExtraTableAfterDeleteContentType() throws DotDataException, D checkUniqueFieldsTable(false, contentType, uniqueTextField, contentlet_1); - APILocator.getContentTypeAPI(APILocator.systemUser()).deleteSync(contentType); + APILocator.getContentTypeAPI(APILocator.systemUser()) + .delete(APILocator.getContentTypeAPI(APILocator.systemUser()).find(contentType.variable())); final List> results = new DotConnect().setSQL("SELECT * FROM unique_fields WHERE supporting_values->>'" + CONTENT_TYPE_ID_ATTR + "' = ?") .addParam(contentType.id())