diff --git a/core/src/main/java/org/alfresco/query/ListBackedPagingResults.java b/core/src/main/java/org/alfresco/query/ListBackedPagingResults.java index 0158b0bdc7..f014b321d5 100644 --- a/core/src/main/java/org/alfresco/query/ListBackedPagingResults.java +++ b/core/src/main/java/org/alfresco/query/ListBackedPagingResults.java @@ -45,6 +45,13 @@ public class ListBackedPagingResults implements PagingResults size = list.size(); hasMore = false; } + + public ListBackedPagingResults(List list, boolean hasMore) + { + this(list); + this.hasMore = hasMore; + } + public ListBackedPagingResults(List list, PagingRequest paging) { // Excerpt diff --git a/packaging/tests/tas-restapi/src/main/java/org/alfresco/rest/core/assertion/ModelsCollectionAssertion.java b/packaging/tests/tas-restapi/src/main/java/org/alfresco/rest/core/assertion/ModelsCollectionAssertion.java index 99ca55e875..7020defb0d 100644 --- a/packaging/tests/tas-restapi/src/main/java/org/alfresco/rest/core/assertion/ModelsCollectionAssertion.java +++ b/packaging/tests/tas-restapi/src/main/java/org/alfresco/rest/core/assertion/ModelsCollectionAssertion.java @@ -30,7 +30,10 @@ import static org.alfresco.utility.report.log.Step.STEP; import java.lang.reflect.InvocationTargetException; import java.lang.reflect.Method; import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collection; import java.util.List; +import java.util.stream.Collectors; import org.alfresco.rest.core.IRestModelsCollection; import org.alfresco.utility.exception.TestConfigurationException; @@ -117,7 +120,7 @@ public class ModelsCollectionAssertion return (C) modelCollection; } - @SuppressWarnings("unchecked") + @SuppressWarnings("unchecked") public C entriesListDoesNotContain(String key, String value) { boolean exist = false; @@ -143,6 +146,53 @@ public class ModelsCollectionAssertion return (C) modelCollection; } + public C entrySetContains(String key, String... expectedValues) + { + return entrySetContains(key, Arrays.stream(expectedValues).collect(Collectors.toSet())); + } + + @SuppressWarnings("unchecked") + public C entrySetContains(String key, Collection expectedValues) + { + Collection actualValues = ((List) modelCollection.getEntries()).stream() + .map(model -> extractValueAsString(model, key)) + .collect(Collectors.toSet()); + + Assert.assertTrue(actualValues.containsAll(expectedValues), String.format("Entry with key: \"%s\" is expected to contain values: %s, but actual values are: %s", + key, expectedValues, actualValues)); + + return (C) modelCollection; + } + + @SuppressWarnings("unchecked") + public C entrySetMatches(String key, Collection expectedValues) + { + Collection actualValues = ((List) modelCollection.getEntries()).stream() + .map(model -> extractValueAsString(model, key)) + .collect(Collectors.toSet()); + + Assert.assertEqualsNoOrder(actualValues, expectedValues, String.format("Entry with key: \"%s\" is expected to match values: %s, but actual values are: %s", + key, expectedValues, actualValues)); + + return (C) modelCollection; + } + + private String extractValueAsString(Model model, String key) + { + String fieldValue; + Object modelObject = loadModel(model); + try { + ObjectMapper mapper = new ObjectMapper(); + String jsonInString = mapper.writeValueAsString(modelObject); + fieldValue = JsonPath.with(jsonInString).get(key); + } catch (Exception e) { + throw new TestConfigurationException(String.format( + "You try to assert field [%s] that doesn't exist in class: [%s]. Exception: %s, Please check your code!", + key, getClass().getCanonicalName(), e.getMessage())); + } + return fieldValue; + } + @SuppressWarnings("unchecked") public C entriesListDoesNotContain(String key) { diff --git a/packaging/tests/tas-restapi/src/test/java/org/alfresco/rest/tags/GetTagsTests.java b/packaging/tests/tas-restapi/src/test/java/org/alfresco/rest/tags/GetTagsTests.java index 5fb830d2c6..f164cbd612 100644 --- a/packaging/tests/tas-restapi/src/test/java/org/alfresco/rest/tags/GetTagsTests.java +++ b/packaging/tests/tas-restapi/src/test/java/org/alfresco/rest/tags/GetTagsTests.java @@ -1,5 +1,9 @@ package org.alfresco.rest.tags; +import static org.alfresco.utility.report.log.Step.STEP; + +import java.util.Set; + import org.alfresco.rest.model.RestErrorModel; import org.alfresco.rest.model.RestTagModel; import org.alfresco.rest.model.RestTagModelsCollection; @@ -9,19 +13,11 @@ import org.alfresco.utility.model.TestGroup; import org.alfresco.utility.testrail.ExecutionType; import org.alfresco.utility.testrail.annotation.TestRail; import org.springframework.http.HttpStatus; -import org.testng.annotations.BeforeClass; import org.testng.annotations.Test; @Test(groups = {TestGroup.REQUIRE_SOLR}) public class GetTagsTests extends TagsDataPrep { - - @BeforeClass(alwaysRun = true) - public void dataPreparation() throws Exception - { - init(); - } - @TestRail(section = { TestGroup.REST_API, TestGroup.TAGS }, executionType = ExecutionType.SANITY, description = "Verify user with Manager role gets tags using REST API and status code is OK (200)") @Test(groups = { TestGroup.REST_API, TestGroup.TAGS, TestGroup.SANITY }) public void getTagsWithManagerRole() throws Exception @@ -192,7 +188,7 @@ public class GetTagsTests extends TagsDataPrep .and().field("hasMoreItems").is("false") .and().field("count").is("0") .and().field("skipCount").is(20000) - .and().field("totalItems").isNull(); + .and().field("totalItems").is(0); } @TestRail(section = { TestGroup.REST_API, TestGroup.TAGS }, executionType = ExecutionType.REGRESSION, @@ -219,11 +215,128 @@ public class GetTagsTests extends TagsDataPrep RestTagModel deletedTag = restClient.authenticateUser(usersWithRoles.getOneUserWithRole(UserRole.SiteManager)) .withCoreAPI().usingResource(document).addTag(removedTag); - restClient.withCoreAPI().usingResource(document).deleteTag(deletedTag); + restClient.authenticateUser(adminUserModel).withCoreAPI().usingTag(deletedTag).deleteTag(); restClient.assertStatusCodeIs(HttpStatus.NO_CONTENT); returnedCollection = restClient.withParams("maxItems=10000").withCoreAPI().getTags(); returnedCollection.assertThat().entriesListIsNotEmpty() .and().entriesListDoesNotContain("tag", removedTag.toLowerCase()); } + + /** + * Verify if exact name filter can be applied. + */ + @Test(groups = { TestGroup.REST_API, TestGroup.TAGS, TestGroup.REGRESSION }) + public void testGetTags_withSingleNameFilter() + { + STEP("Get tags with names filter using EQUALS and expect one item in result"); + returnedCollection = restClient.authenticateUser(adminUserModel) + .withParams("where=(tag='" + documentTag.getTag() + "')") + .withCoreAPI() + .getTags(); + + restClient.assertStatusCodeIs(HttpStatus.OK); + returnedCollection.assertThat() + .entrySetMatches("tag", Set.of(documentTagValue.toLowerCase())); + } + + /** + * Verify if multiple names can be applied as a filter. + */ + @Test(groups = { TestGroup.REST_API, TestGroup.TAGS, TestGroup.REGRESSION }) + public void testGetTags_withTwoNameFilters() + { + STEP("Get tags with names filter using IN and expect two items in result"); + returnedCollection = restClient.authenticateUser(adminUserModel) + .withParams("where=(tag IN ('" + documentTag.getTag() + "', '" + folderTag.getTag() + "'))") + .withCoreAPI() + .getTags(); + + restClient.assertStatusCodeIs(HttpStatus.OK); + returnedCollection.assertThat() + .entrySetMatches("tag", Set.of(documentTagValue.toLowerCase(), folderTagValue.toLowerCase())); + } + + /** + * Verify if alike name filter can be applied. + */ + @Test(groups = { TestGroup.REST_API, TestGroup.TAGS, TestGroup.REGRESSION }) + public void testGetTags_whichNamesStartsWithOrphan() + { + STEP("Get tags with names filter using MATCHES and expect one item in result"); + returnedCollection = restClient.authenticateUser(adminUserModel) + .withParams("where=(tag MATCHES ('orphan*'))") + .withCoreAPI() + .getTags(); + + restClient.assertStatusCodeIs(HttpStatus.OK); + returnedCollection.assertThat() + .entrySetContains("tag", orphanTag.getTag().toLowerCase()); + } + + /** + * Verify that tags can be filtered by exact name and alike name at the same time. + */ + @Test(groups = { TestGroup.REST_API, TestGroup.TAGS, TestGroup.REGRESSION }) + public void testGetTags_withExactNameAndAlikeFilters() + { + STEP("Get tags with names filter using EQUALS and MATCHES and expect four items in result"); + returnedCollection = restClient.authenticateUser(adminUserModel) + .withParams("where=(tag='" + orphanTag.getTag() + "' OR tag MATCHES ('*tag*'))") + .withCoreAPI() + .getTags(); + + restClient.assertStatusCodeIs(HttpStatus.OK); + returnedCollection.assertThat() + .entrySetContains("tag", documentTagValue.toLowerCase(), documentTagValue2.toLowerCase(), folderTagValue.toLowerCase(), orphanTag.getTag().toLowerCase()); + } + + /** + * Verify if multiple alike filters can be applied. + */ + @Test(groups = { TestGroup.REST_API, TestGroup.TAGS, TestGroup.REGRESSION }) + public void testGetTags_withTwoAlikeFilters() + { + STEP("Get tags applying names filter using MATCHES twice and expect four items in result"); + returnedCollection = restClient.authenticateUser(adminUserModel) + .withParams("where=(tag MATCHES ('orphan*') OR tag MATCHES ('tag*'))") + .withCoreAPI() + .getTags(); + + restClient.assertStatusCodeIs(HttpStatus.OK); + returnedCollection.assertThat() + .entrySetContains("tag", documentTagValue.toLowerCase(), documentTagValue2.toLowerCase(), folderTagValue.toLowerCase(), orphanTag.getTag().toLowerCase()); + } + + /** + * Verify that providing incorrect field name in where query will result with 400 (Bad Request). + */ + @Test(groups = { TestGroup.REST_API, TestGroup.TAGS, TestGroup.REGRESSION }) + public void testGetTags_withWrongWherePropertyNameAndExpect400() + { + STEP("Try to get tags with names filter using EQUALS and wrong property name and expect 400"); + returnedCollection = restClient.authenticateUser(adminUserModel) + .withParams("where=(name=gat)") + .withCoreAPI() + .getTags(); + + restClient.assertStatusCodeIs(HttpStatus.BAD_REQUEST) + .assertLastError().containsSummary("Where query error: property with name: name is not expected"); + } + + /** + * Verify tht AND operator is not supported in where query and expect 400 (Bad Request). + */ + @Test(groups = { TestGroup.REST_API, TestGroup.TAGS, TestGroup.REGRESSION }) + public void testGetTags_queryAndOperatorNotSupported() + { + STEP("Try to get tags applying names filter using AND operator and expect 400"); + returnedCollection = restClient.authenticateUser(adminUserModel) + .withParams("where=(name=tag AND name IN ('tag-', 'gat'))") + .withCoreAPI() + .getTags(); + + restClient.assertStatusCodeIs(HttpStatus.BAD_REQUEST) + .assertLastError().containsSummary("An invalid WHERE query was received. Unsupported Predicate"); + } } diff --git a/packaging/tests/tas-restapi/src/test/java/org/alfresco/rest/tags/TagsDataPrep.java b/packaging/tests/tas-restapi/src/test/java/org/alfresco/rest/tags/TagsDataPrep.java index 84a9fe37a1..4d10915945 100644 --- a/packaging/tests/tas-restapi/src/test/java/org/alfresco/rest/tags/TagsDataPrep.java +++ b/packaging/tests/tas-restapi/src/test/java/org/alfresco/rest/tags/TagsDataPrep.java @@ -24,7 +24,7 @@ public class TagsDataPrep extends RestTest protected static FileModel document; protected static FolderModel folder; protected static String documentTagValue, documentTagValue2, folderTagValue; - protected static RestTagModel documentTag, documentTag2, folderTag, returnedModel; + protected static RestTagModel documentTag, documentTag2, folderTag, orphanTag, returnedModel; protected static RestTagModelsCollection returnedCollection; @BeforeClass @@ -47,16 +47,17 @@ public class TagsDataPrep extends RestTest documentTag = restClient.withCoreAPI().usingResource(document).addTag(documentTagValue); documentTag2 = restClient.withCoreAPI().usingResource(document).addTag(documentTagValue2); folderTag = restClient.withCoreAPI().usingResource(folder).addTag(folderTagValue); + orphanTag = restClient.withCoreAPI().createSingleTag(RestTagModel.builder().tag(RandomData.getRandomName("orphan-tag")).create()); // Allow indexing to complete. Utility.sleep(500, 60000, () -> - { - returnedCollection = restClient.withParams("maxItems=10000").withCoreAPI().getTags(); - returnedCollection.assertThat().entriesListContains("tag", documentTagValue.toLowerCase()) - .and().entriesListContains("tag", documentTagValue2.toLowerCase()) - .and().entriesListContains("tag", folderTagValue.toLowerCase()); - }); - + { + returnedCollection = restClient.withParams("maxItems=10000", "where=(tag MATCHES ('*tag*'))") + .withCoreAPI().getTags(); + returnedCollection.assertThat().entriesListContains("tag", documentTagValue.toLowerCase()) + .and().entriesListContains("tag", documentTagValue2.toLowerCase()) + .and().entriesListContains("tag", folderTagValue.toLowerCase()); + }); } protected RestTagModel createTagForDocument(FileModel document) diff --git a/remote-api/src/main/java/org/alfresco/rest/api/impl/TagsImpl.java b/remote-api/src/main/java/org/alfresco/rest/api/impl/TagsImpl.java index 04366331ab..04d14e4781 100644 --- a/remote-api/src/main/java/org/alfresco/rest/api/impl/TagsImpl.java +++ b/remote-api/src/main/java/org/alfresco/rest/api/impl/TagsImpl.java @@ -25,10 +25,16 @@ */ package org.alfresco.rest.api.impl; +import static org.alfresco.rest.antlr.WhereClauseParser.EQUALS; +import static org.alfresco.rest.antlr.WhereClauseParser.IN; +import static org.alfresco.rest.antlr.WhereClauseParser.MATCHES; + import java.util.AbstractList; import java.util.ArrayList; +import java.util.Collection; import java.util.Collections; import java.util.HashMap; +import java.util.HashSet; import java.util.List; import java.util.Map; import java.util.Objects; @@ -51,6 +57,9 @@ import org.alfresco.rest.framework.core.exceptions.UnsupportedResourceOperationE import org.alfresco.rest.framework.resource.parameters.CollectionWithPagingInfo; import org.alfresco.rest.framework.resource.parameters.Paging; import org.alfresco.rest.framework.resource.parameters.Parameters; +import org.alfresco.rest.framework.resource.parameters.where.Query; +import org.alfresco.rest.framework.resource.parameters.where.QueryHelper; +import org.alfresco.rest.framework.resource.parameters.where.QueryImpl; import org.alfresco.service.Experimental; import org.alfresco.service.cmr.repository.NodeRef; import org.alfresco.service.cmr.repository.StoreRef; @@ -68,7 +77,8 @@ import org.apache.commons.collections.CollectionUtils; */ public class TagsImpl implements Tags { - private static final Object PARAM_INCLUDE_COUNT = "count"; + private static final String PARAM_INCLUDE_COUNT = "count"; + private static final String PARAM_WHERE_TAG = "tag"; static final String NOT_A_VALID_TAG = "An invalid parameter has been supplied"; static final String NO_PERMISSION_TO_MANAGE_A_TAG = "Current user does not have permission to manage a tag"; @@ -154,17 +164,18 @@ public class TagsImpl implements Tags taggingService.deleteTag(storeRef, tagValue); } + @Override public CollectionWithPagingInfo getTags(StoreRef storeRef, Parameters params) { - Paging paging = params.getPaging(); - PagingResults> results = taggingService.getTags(storeRef, Util.getPagingRequest(paging)); - taggingService.getPagedTags(storeRef, 0, paging.getMaxItems()); + Paging paging = params.getPaging(); + Map> namesFilters = resolveTagNamesQuery(params.getQuery()); + PagingResults> results = taggingService.getTags(storeRef, Util.getPagingRequest(paging), namesFilters.get(EQUALS), namesFilters.get(MATCHES)); + Integer totalItems = results.getTotalResultCount().getFirst(); List> page = results.getPage(); - List tags = new ArrayList(page.size()); - List> tagsByCount = null; - Map tagsByCountMap = new HashMap(); - + List tags = new ArrayList<>(page.size()); + List> tagsByCount; + Map tagsByCountMap = new HashMap<>(); if (params.getInclude().contains(PARAM_INCLUDE_COUNT)) { tagsByCount = taggingService.findTaggedNodesAndCountByTagName(storeRef); @@ -183,7 +194,7 @@ public class TagsImpl implements Tags tags.add(selectedTag); } - return CollectionWithPagingInfo.asPaged(paging, tags, results.hasMoreItems(), (totalItems == null ? null : totalItems.intValue())); + return CollectionWithPagingInfo.asPaged(paging, tags, results.hasMoreItems(), totalItems); } public NodeRef validateTag(String tagId) @@ -291,4 +302,38 @@ public class TagsImpl implements Tags throw new PermissionDeniedException(NO_PERMISSION_TO_MANAGE_A_TAG); } } + + /** + * Method resolves where query looking for clauses: EQUALS, IN or MATCHES. + * Expected values for EQUALS and IN will be merged under EQUALS clause. + * @param namesQuery Where query with expected tag name(s). + * @return Map of expected exact and alike tag names. + */ + private Map> resolveTagNamesQuery(final Query namesQuery) + { + if (namesQuery == null || namesQuery == QueryImpl.EMPTY) + { + return Collections.emptyMap(); + } + + final Map> properties = QueryHelper + .resolve(namesQuery) + .usingOrOperator() + .withoutNegations() + .getProperty(PARAM_WHERE_TAG) + .getExpectedValuesForAnyOf(EQUALS, IN, MATCHES) + .skipNegated(); + + return properties.entrySet().stream() + .collect(Collectors.groupingBy((entry) -> { + if (entry.getKey() == EQUALS || entry.getKey() == IN) + { + return EQUALS; + } + else + { + return MATCHES; + } + }, Collectors.flatMapping((entry) -> entry.getValue().stream().map(String::toLowerCase), Collectors.toCollection(HashSet::new)))); + } } diff --git a/remote-api/src/main/java/org/alfresco/rest/api/model/Tag.java b/remote-api/src/main/java/org/alfresco/rest/api/model/Tag.java index bcbd4e6fd4..4f514e6e88 100644 --- a/remote-api/src/main/java/org/alfresco/rest/api/model/Tag.java +++ b/remote-api/src/main/java/org/alfresco/rest/api/model/Tag.java @@ -101,36 +101,20 @@ public class Tag implements Comparable } @Override - public int hashCode() + public boolean equals(Object o) { - final int prime = 31; - int result = 1; - result = prime * result + ((nodeRef == null) ? 0 : nodeRef.hashCode()); - return result; + if (this == o) + return true; + if (o == null || getClass() != o.getClass()) + return false; + Tag tag1 = (Tag) o; + return Objects.equals(nodeRef, tag1.nodeRef) && Objects.equals(tag, tag1.tag) && Objects.equals(count, tag1.count); } - /* - * Tags are equal if they have the same NodeRef - * - * (non-Javadoc) - * @see java.lang.Object#equals(java.lang.Object) - */ @Override - public boolean equals(Object obj) + public int hashCode() { - if (this == obj) - return true; - if (obj == null) - return false; - if (getClass() != obj.getClass()) - return false; - Tag other = (Tag) obj; - if (nodeRef == null) { - if (other.nodeRef != null) - return false; - } else if (!nodeRef.equals(other.nodeRef)) - return false; - return true; + return Objects.hash(nodeRef, tag, count); } @Override diff --git a/remote-api/src/main/java/org/alfresco/rest/api/tags/TagsEntityResource.java b/remote-api/src/main/java/org/alfresco/rest/api/tags/TagsEntityResource.java index 8b30d637c7..abd53c3f0f 100644 --- a/remote-api/src/main/java/org/alfresco/rest/api/tags/TagsEntityResource.java +++ b/remote-api/src/main/java/org/alfresco/rest/api/tags/TagsEntityResource.java @@ -59,9 +59,8 @@ public class TagsEntityResource implements EntityResourceAction.Read, } /** - * * Returns a paged list of all currently used tags in the store workspace://SpacesStore for the current tenant. - * + * GET /tags */ @Override @WebApiDescription(title="A paged list of all tags in the network.") diff --git a/remote-api/src/main/java/org/alfresco/rest/framework/resource/parameters/where/BasicQueryWalker.java b/remote-api/src/main/java/org/alfresco/rest/framework/resource/parameters/where/BasicQueryWalker.java new file mode 100644 index 0000000000..c046d249a2 --- /dev/null +++ b/remote-api/src/main/java/org/alfresco/rest/framework/resource/parameters/where/BasicQueryWalker.java @@ -0,0 +1,259 @@ +/* + * #%L + * Alfresco Remote API + * %% + * Copyright (C) 2005 - 2023 Alfresco Software Limited + * %% + * This file is part of the Alfresco software. + * If the software was purchased under a paid Alfresco license, the terms of + * the paid license agreement will prevail. Otherwise, the software is + * provided under the following open source license terms: + * + * Alfresco is free software: you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Alfresco is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with Alfresco. If not, see . + * #L% + */ +package org.alfresco.rest.framework.resource.parameters.where; + +import java.util.Arrays; +import java.util.Collection; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.stream.Collectors; + +import org.alfresco.rest.antlr.WhereClauseParser; +import org.apache.commons.collections4.CollectionUtils; +import org.apache.commons.lang3.StringUtils; + +/** + * Basic implementation of {@link QueryHelper.WalkerCallbackAdapter} providing universal handling of Where query clauses. + * This implementation supports AND operator and all clause types. + * Be default, walker verifies strictly if expected or unexpected properties, and it's comparison types are present in query + * and throws {@link InvalidQueryException} if they are missing. + */ +public class BasicQueryWalker extends QueryHelper.WalkerCallbackAdapter +{ + private static final String EQUALS_AND_IN_NOT_ALLOWED_TOGETHER = "Where query error: cannot use '=' (EQUALS) AND 'IN' clauses with same property: %s"; + private static final String MISSING_PROPERTY = "Where query error: property with name: %s not present"; + static final String MISSING_CLAUSE_TYPE = "Where query error: property with name: %s expects clause: %s"; + static final String MISSING_ANY_CLAUSE_OF_TYPE = "Where query error: property with name: %s expects at least one of clauses: %s"; + private static final String PROPERTY_NOT_EXPECTED = "Where query error: property with name: %s is not expected"; + private static final String PROPERTY_NOT_NEGATABLE = "Where query error: property with name: %s cannot be negated"; + private static final String PROPERTY_NAMES_EMPTY = "Cannot verify WHERE query without expected property names"; + + private Collection expectedPropertyNames; + private final Map properties; + protected boolean clausesNegatable = true; + protected boolean validateStrictly = true; + + public BasicQueryWalker() + { + this.properties = new HashMap<>(); + } + + public BasicQueryWalker(final String... expectedPropertyNames) + { + this(); + this.expectedPropertyNames = Set.of(expectedPropertyNames); + } + + public BasicQueryWalker(final Collection expectedPropertyNames) + { + this(); + this.expectedPropertyNames = expectedPropertyNames; + } + + public void setClausesNegatable(final boolean clausesNegatable) + { + this.clausesNegatable = clausesNegatable; + } + + public void setValidateStrictly(boolean validateStrictly) + { + this.validateStrictly = validateStrictly; + } + + @Override + public void exists(String propertyName, boolean negated) + { + verifyPropertyExpectedness(propertyName); + verifyClausesNegatability(negated, propertyName); + addProperties(propertyName, WhereClauseParser.EXISTS, negated); + } + + @Override + public void between(String propertyName, String firstValue, String secondValue, boolean negated) + { + verifyPropertyExpectedness(propertyName); + verifyClausesNegatability(negated, propertyName); + addProperties(propertyName, WhereClauseParser.BETWEEN, negated, firstValue, secondValue); + } + + @Override + public void comparison(int type, String propertyName, String propertyValue, boolean negated) + { + verifyPropertyExpectedness(propertyName); + verifyClausesNegatability(negated, propertyName); + if (WhereClauseParser.EQUALS == type && isAndSupported() && containsProperty(propertyName, WhereClauseParser.IN, negated)) + { + throw new InvalidQueryException(String.format(EQUALS_AND_IN_NOT_ALLOWED_TOGETHER, propertyName)); + } + + addProperties(propertyName, type, negated, propertyValue); + } + + @Override + public void in(String propertyName, boolean negated, String... propertyValues) + { + verifyPropertyExpectedness(propertyName); + verifyClausesNegatability(negated, propertyName); + if (isAndSupported() && containsProperty(propertyName, WhereClauseParser.EQUALS, negated)) + { + throw new InvalidQueryException(String.format(EQUALS_AND_IN_NOT_ALLOWED_TOGETHER, propertyName)); + } + + addProperties(propertyName, WhereClauseParser.IN, negated, propertyValues); + } + + @Override + public void matches(final String propertyName, String propertyValue, boolean negated) + { + verifyPropertyExpectedness(propertyName); + verifyClausesNegatability(negated, propertyName); + addProperties(propertyName, WhereClauseParser.MATCHES, negated, propertyValue); + } + + @Override + public void and() + { + // Don't need to do anything here - it's enough to enable AND operator. + // OR is not supported at the same time. + } + + /** + * Verify if property is expected, if not throws {@link InvalidQueryException}. + */ + protected void verifyPropertyExpectedness(final String propertyName) + { + if (validateStrictly && CollectionUtils.isNotEmpty(expectedPropertyNames) && !this.expectedPropertyNames.contains(propertyName)) + { + throw new InvalidQueryException(String.format(PROPERTY_NOT_EXPECTED, propertyName)); + } + else if (validateStrictly && CollectionUtils.isEmpty(expectedPropertyNames)) + { + throw new IllegalStateException(PROPERTY_NAMES_EMPTY); + } + } + + /** + * Verify if clause negations are allowed, if not throws {@link InvalidQueryException}. + */ + protected void verifyClausesNegatability(final boolean negated, final String propertyName) + { + if (!clausesNegatable && negated) + { + throw new InvalidQueryException(String.format(PROPERTY_NOT_NEGATABLE, propertyName)); + } + } + + protected boolean isAndSupported() + { + try + { + and(); + return true; + } + catch (InvalidQueryException ignore) + { + return false; + } + } + + protected void addProperties(final String propertyName, final int clauseType, final String... propertyValues) + { + this.addProperties(propertyName, clauseType, false, propertyValues); + } + + protected void addProperties(final String propertyName, final int clauseType, final boolean negated, final String... propertyValues) + { + final WhereProperty.ClauseType type = WhereProperty.ClauseType.of(clauseType, negated); + final Set propertiesToAdd = Optional.ofNullable(propertyValues).map(Set::of).orElse(Collections.emptySet()); + if (this.containsProperty(propertyName)) + { + this.properties.get(propertyName).addValuesToType(type, propertiesToAdd); + } + else + { + this.properties.put(propertyName, new WhereProperty(propertyName, type, propertiesToAdd, validateStrictly)); + } + } + + protected boolean containsProperty(final String propertyName) + { + return this.properties.containsKey(propertyName); + } + + protected boolean containsProperty(final String propertyName, final int clauseType, final boolean negated) + { + return this.properties.containsKey(propertyName) && this.properties.get(propertyName).containsType(clauseType, negated); + } + + @Override + public Collection getProperty(String propertyName, int type, boolean negated) + { + return this.getProperty(propertyName).getExpectedValuesFor(type, negated); + } + + public WhereProperty getProperty(final String propertyName) + { + if (validateStrictly && !this.containsProperty(propertyName)) + { + throw new InvalidQueryException(String.format(MISSING_PROPERTY, propertyName)); + } + + return this.properties.get(propertyName); + } + + public List getProperties(final String... propertyNames) + { + return Arrays.stream(propertyNames) + .filter(StringUtils::isNotBlank) + .distinct() + .peek(propertyName -> { + if (validateStrictly && !this.containsProperty(propertyName)) + { + throw new InvalidQueryException(String.format(MISSING_PROPERTY, propertyName)); + } + }) + .map(this.properties::get) + .collect(Collectors.toList()); + } + + public Map getPropertiesAsMap(final String... propertyNames) + { + return Arrays.stream(propertyNames) + .filter(StringUtils::isNotBlank) + .distinct() + .peek(propertyName -> { + if (validateStrictly && !this.containsProperty(propertyName)) + { + throw new InvalidQueryException(String.format(MISSING_PROPERTY, propertyName)); + } + }) + .collect(Collectors.toMap(propertyName -> propertyName, this.properties::get)); + } +} diff --git a/remote-api/src/main/java/org/alfresco/rest/framework/resource/parameters/where/QueryHelper.java b/remote-api/src/main/java/org/alfresco/rest/framework/resource/parameters/where/QueryHelper.java index 65e951b679..394e1aecd1 100644 --- a/remote-api/src/main/java/org/alfresco/rest/framework/resource/parameters/where/QueryHelper.java +++ b/remote-api/src/main/java/org/alfresco/rest/framework/resource/parameters/where/QueryHelper.java @@ -2,7 +2,7 @@ * #%L * Alfresco Remote API * %% - * Copyright (C) 2005 - 2016 Alfresco Software Limited + * Copyright (C) 2005 - 2023 Alfresco Software Limited * %% * This file is part of the Alfresco software. * If the software was purchased under a paid Alfresco license, the terms of @@ -25,10 +25,19 @@ */ package org.alfresco.rest.framework.resource.parameters.where; +import static org.alfresco.rest.antlr.WhereClauseParser.BETWEEN; +import static org.alfresco.rest.antlr.WhereClauseParser.EQUALS; +import static org.alfresco.rest.antlr.WhereClauseParser.EXISTS; +import static org.alfresco.rest.antlr.WhereClauseParser.IN; + import java.util.ArrayList; +import java.util.Collection; import java.util.Collections; import java.util.LinkedList; import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.function.Function; import org.alfresco.rest.antlr.WhereClauseParser; import org.antlr.runtime.tree.CommonTree; @@ -45,14 +54,19 @@ public abstract class QueryHelper /** * An interface used when walking a query tree. Calls are made to methods when the particular clause is encountered. */ - public static interface WalkerCallback + public interface WalkerCallback { + InvalidQueryException UNSUPPORTED = new InvalidQueryException("Unsupported Predicate"); + /** * Called any time an EXISTS clause is encountered. * @param propertyName Name of the property * @param negated returns true if "NOT EXISTS" was used */ - void exists(String propertyName, boolean negated); + default void exists(String propertyName, boolean negated) + { + throw UNSUPPORTED; + } /** * Called any time a BETWEEN clause is encountered. @@ -61,12 +75,18 @@ public abstract class QueryHelper * @param secondValue String * @param negated returns true if "NOT BETWEEN" was used */ - void between(String propertyName, String firstValue, String secondValue, boolean negated); + default void between(String propertyName, String firstValue, String secondValue, boolean negated) + { + throw UNSUPPORTED; + } /** * One of EQUALS LESSTHAN GREATERTHAN LESSTHANOREQUALS GREATERTHANOREQUALS; */ - void comparison(int type, String propertyName, String propertyValue, boolean negated); + default void comparison(int type, String propertyName, String propertyValue, boolean negated) + { + throw UNSUPPORTED; + } /** * Called any time an IN clause is encountered. @@ -74,7 +94,10 @@ public abstract class QueryHelper * @param negated returns true if "NOT IN" was used * @param propertyValues the property values */ - void in(String property, boolean negated, String... propertyValues); + default void in(String property, boolean negated, String... propertyValues) + { + throw UNSUPPORTED; + } /** * Called any time a MATCHES clause is encountered. @@ -82,42 +105,37 @@ public abstract class QueryHelper * @param propertyValue String * @param negated returns true if "NOT MATCHES" was used */ - void matches(String property, String propertyValue, boolean negated); + default void matches(String property, String propertyValue, boolean negated) + { + throw UNSUPPORTED; + } /** * Called any time an AND is encountered. - */ - void and(); + */ + default void and() + { + throw UNSUPPORTED; + } /** * Called any time an OR is encountered. - */ - void or(); + */ + default void or() + { + throw UNSUPPORTED; + } + + default Collection getProperty(String propertyName, int type, boolean negated) + { + throw UNSUPPORTED; + } } /** * Default implementation. Override the methods you are interested in. If you don't * override the methods then an InvalidQueryException will be thrown. */ - public static class WalkerCallbackAdapter implements WalkerCallback - { - private static final String UNSUPPORTED_TEXT = "Unsupported Predicate"; - protected static final InvalidQueryException UNSUPPORTED = new InvalidQueryException(UNSUPPORTED_TEXT); - - @Override - public void exists(String propertyName, boolean negated) { throw UNSUPPORTED;} - @Override - public void between(String propertyName, String firstValue, String secondValue, boolean negated) { throw UNSUPPORTED;} - @Override - public void comparison(int type, String propertyName, String propertyValue, boolean negated) { throw UNSUPPORTED;} - @Override - public void in(String propertyName, boolean negated, String... propertyValues) { throw UNSUPPORTED;} - @Override - public void matches(String property, String value, boolean negated) { throw UNSUPPORTED;} - @Override - public void and() {throw UNSUPPORTED;} - @Override - public void or() {throw UNSUPPORTED;} - } + public static class WalkerCallbackAdapter implements WalkerCallback {} /** * Walks a query with a callback for each operation @@ -146,7 +164,7 @@ public abstract class QueryHelper if (tree != null) { switch (tree.getType()) { - case WhereClauseParser.EXISTS: + case EXISTS: if (WhereClauseParser.PROPERTYNAME == tree.getChild(0).getType()) { callback.exists(tree.getChild(0).getText(), negated); @@ -160,7 +178,7 @@ public abstract class QueryHelper return; } break; - case WhereClauseParser.IN: + case IN: if (WhereClauseParser.PROPERTYNAME == tree.getChild(0).getType()) { List children = getChildren(tree); @@ -174,14 +192,14 @@ public abstract class QueryHelper return; } break; - case WhereClauseParser.BETWEEN: + case BETWEEN: if (WhereClauseParser.PROPERTYNAME == tree.getChild(0).getType()) { callback.between(tree.getChild(0).getText(), stripQuotes(tree.getChild(1).getText()), stripQuotes(tree.getChild(2).getText()), negated); return; } break; - case WhereClauseParser.EQUALS: //fall through (comparison) + case EQUALS: //fall through (comparison) case WhereClauseParser.LESSTHAN: //fall through (comparison) case WhereClauseParser.GREATERTHAN: //fall through (comparison) case WhereClauseParser.LESSTHANOREQUALS: //fall through (comparison) @@ -286,4 +304,180 @@ public abstract class QueryHelper } return toBeStripped; //default to return the String unchanged. } + + public static QueryResolver.WalkerSpecifier resolve(final Query query) + { + return new QueryResolver.WalkerSpecifier(query); + } + + /** + * Helper class allowing WHERE query resolving using query walker. By default {@link BasicQueryWalker} is used, but different walker can be supplied. + */ + public static abstract class QueryResolver> + { + private final Query query; + protected WalkerCallback queryWalker; + protected Function, BasicQueryWalker> orQueryWalkerSupplier; + protected boolean clausesNegatable = true; + protected boolean validateLeniently = false; + protected abstract S self(); + + public QueryResolver(Query query) + { + this.query = query; + } + + /** + * Get property expected values. + * @param propertyName Property name. + * @param clauseType Property comparison type. + * @param negated Comparison type negation. + * @return Map composed of all comparators and compared values. + */ + public Collection getProperty(final String propertyName, final int clauseType, final boolean negated) + { + processQuery(propertyName); + return queryWalker.getProperty(propertyName, clauseType, negated); + } + + protected void processQuery(final String... propertyNames) + { + if (queryWalker == null) + { + if (orQueryWalkerSupplier != null) + { + queryWalker = orQueryWalkerSupplier.apply(Set.of(propertyNames)); + } + else + { + queryWalker = new BasicQueryWalker(propertyNames); + } + } + if (queryWalker instanceof BasicQueryWalker) + { + ((BasicQueryWalker) queryWalker).setClausesNegatable(clausesNegatable); + ((BasicQueryWalker) queryWalker).setValidateStrictly(!validateLeniently); + } + walk(query, queryWalker); + } + + /** + * Helper class providing methods related with default query walker {@link BasicQueryWalker}. + */ + public static class DefaultWalkerOperations> extends QueryResolver + { + public DefaultWalkerOperations(Query query) + { + super(query); + } + + @SuppressWarnings("unchecked") + @Override + protected R self() + { + return (R) this; + } + + /** + * Specifies that query properties and comparison types should NOT be verified strictly. + */ + public R leniently() + { + this.validateLeniently = true; + return self(); + } + + /** + * Specifies that clause types negations are not allowed in query. + */ + public R withoutNegations() + { + this.clausesNegatable = false; + return self(); + } + + /** + * Get property with expected values. + * @param propertyName Property name. + * @return Map composed of all comparators and compared values. + */ + public WhereProperty getProperty(final String propertyName) + { + processQuery(propertyName); + return ((BasicQueryWalker) this.queryWalker).getProperty(propertyName); + } + + /** + * Get multiple properties with it's expected values. + * @param propertyNames Property names. + * @return List of maps composed of all comparators and compared values. + */ + public List getProperties(final String... propertyNames) + { + processQuery(propertyNames); + return ((BasicQueryWalker) this.queryWalker).getProperties(propertyNames); + } + + /** + * Get multiple properties with it's expected values. + * @param propertyNames Property names. + * @return Map composed of property names and maps composed of all comparators and compared values. + */ + public Map getPropertiesAsMap(final String... propertyNames) + { + processQuery(propertyNames); + return ((BasicQueryWalker) this.queryWalker).getPropertiesAsMap(propertyNames); + } + } + + /** + * Helper class allowing to specify custom {@link WalkerCallback} implementation or {@link BasicQueryWalker} extension. + */ + public static class WalkerSpecifier extends DefaultWalkerOperations + { + public WalkerSpecifier(Query query) + { + super(query); + } + + @Override + protected WalkerSpecifier self() + { + return this; + } + + /** + * Specifies that OR operator instead of AND should be used while resolving the query. + */ + public DefaultWalkerOperations> usingOrOperator() + { + this.orQueryWalkerSupplier = (propertyNames) -> new BasicQueryWalker(propertyNames) + { + @Override + public void or() {/*Enable OR support, disable AND support*/} + @Override + public void and() {throw UNSUPPORTED;} + }; + return this; + } + + /** + * Allows to specify custom {@link BasicQueryWalker} extension, which should be used to resolve the query. + */ + public DefaultWalkerOperations> usingWalker(final T queryWalker) + { + this.queryWalker = queryWalker; + return this; + } + + /** + * Allows to specify custom {@link WalkerCallback} implementation, which should be used to resolve the query. + */ + public QueryResolver> usingWalker(final T queryWalker) + { + this.queryWalker = queryWalker; + return this; + } + } + } } diff --git a/remote-api/src/main/java/org/alfresco/rest/framework/resource/parameters/where/WhereProperty.java b/remote-api/src/main/java/org/alfresco/rest/framework/resource/parameters/where/WhereProperty.java new file mode 100644 index 0000000000..f99fe7ef03 --- /dev/null +++ b/remote-api/src/main/java/org/alfresco/rest/framework/resource/parameters/where/WhereProperty.java @@ -0,0 +1,351 @@ +/* + * #%L + * Alfresco Remote API + * %% + * Copyright (C) 2005 - 2023 Alfresco Software Limited + * %% + * This file is part of the Alfresco software. + * If the software was purchased under a paid Alfresco license, the terms of + * the paid license agreement will prevail. Otherwise, the software is + * provided under the following open source license terms: + * + * Alfresco is free software: you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Alfresco is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with Alfresco. If not, see . + * #L% + */ +package org.alfresco.rest.framework.resource.parameters.where; + +import static java.util.function.Predicate.not; + +import static org.alfresco.rest.framework.resource.parameters.where.BasicQueryWalker.MISSING_ANY_CLAUSE_OF_TYPE; +import static org.alfresco.rest.framework.resource.parameters.where.BasicQueryWalker.MISSING_CLAUSE_TYPE; + +import java.util.Arrays; +import java.util.Collection; +import java.util.HashMap; +import java.util.HashSet; +import java.util.Map; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +import org.alfresco.rest.antlr.WhereClauseParser; + +/** + * Map composed of property comparison type and compared values. + * Map key is clause (comparison) type. + */ +public class WhereProperty extends HashMap> +{ + private final String name; + private boolean validateStrictly; + + public WhereProperty(final String name, final ClauseType clauseType, final Collection values) + { + super(Map.of(clauseType, new HashSet<>(values))); + this.name = name; + this.validateStrictly = true; + } + + public WhereProperty(final String name, final ClauseType clauseType, final Collection values, final boolean validateStrictly) + { + this(name, clauseType, values); + this.validateStrictly = validateStrictly; + } + + public String getName() + { + return name; + } + + public void addValuesToType(final ClauseType clauseType, final Collection values) + { + if (this.containsKey(clauseType)) + { + this.get(clauseType).addAll(values); + } + else + { + this.put(clauseType, new HashSet<>(values)); + } + } + + public boolean containsType(final ClauseType clauseType) + { + return this.containsKey(clauseType); + } + + public boolean containsType(final int clauseType, final boolean negated) + { + return this.containsKey(ClauseType.of(clauseType, negated)); + } + + public boolean containsAllTypes(final ClauseType... clauseType) + { + return Arrays.stream(clauseType).distinct().filter(this::containsKey).count() == clauseType.length; + } + + public boolean containsAnyOfTypes(final ClauseType... clauseType) + { + return Arrays.stream(clauseType).distinct().anyMatch(this::containsKey); + } + + public Collection getExpectedValuesFor(final ClauseType clauseType) + { + verifyAllClausesPresence(clauseType); + return this.get(clauseType); + } + + public HashMap> getExpectedValuesForAllOf(final ClauseType... clauseTypes) + { + verifyAllClausesPresence(clauseTypes); + return Arrays.stream(clauseTypes) + .distinct() + .collect(Collectors.toMap(type -> type, this::get, (type1, type2) -> type1, MultiTypeNegatableValuesMap::new)); + } + + public HashMap> getExpectedValuesForAnyOf(final ClauseType... clauseTypes) + { + verifyAnyClausesPresence(clauseTypes); + return Arrays.stream(clauseTypes) + .distinct() + .collect(Collectors.toMap(type -> type, this::get, (type1, type2) -> type1, MultiTypeNegatableValuesMap::new)); + } + + public Collection getExpectedValuesFor(final int clauseType, final boolean negated) + { + verifyAllClausesPresence(ClauseType.of(clauseType, negated)); + return this.get(ClauseType.of(clauseType, negated)); + } + + public NegatableValuesMap getExpectedValuesFor(final int clauseType) + { + verifyAllClausesPresence(clauseType); + final NegatableValuesMap values = new NegatableValuesMap(); + final ClauseType type = ClauseType.of(clauseType); + final ClauseType negatedType = type.negate(); + if (this.containsKey(type)) + { + values.put(false, this.get(type)); + } + if (this.containsKey(negatedType)) + { + values.put(true, this.get(negatedType)); + } + return values; + } + + public MultiTypeNegatableValuesMap getExpectedValuesForAllOf(final int... clauseTypes) + { + verifyAllClausesPresence(clauseTypes); + return getExpectedValuesFor(clauseTypes); + } + + public MultiTypeNegatableValuesMap getExpectedValuesForAnyOf(final int... clauseTypes) + { + verifyAnyClausesPresence(clauseTypes); + return getExpectedValuesFor(clauseTypes); + } + + private MultiTypeNegatableValuesMap getExpectedValuesFor(final int... clauseTypes) + { + final MultiTypeNegatableValuesMap values = new MultiTypeNegatableValuesMap(); + Arrays.stream(clauseTypes).distinct().forEach(clauseType -> { + final ClauseType type = ClauseType.of(clauseType); + final ClauseType negatedType = type.negate(); + if (this.containsKey(type)) + { + values.put(type, this.get(type)); + } + if (this.containsKey(negatedType)) + { + values.put(negatedType, this.get(negatedType)); + } + }); + + return values; + } + + /** + * Verify if all specified clause types are present in this map, if not than throw {@link InvalidQueryException}. + */ + private void verifyAllClausesPresence(final ClauseType... clauseTypes) + { + if (validateStrictly) + { + Arrays.stream(clauseTypes).distinct().forEach(clauseType -> { + if (!this.containsType(clauseType)) + { + throw new InvalidQueryException(String.format(MISSING_CLAUSE_TYPE, this.name, WhereClauseParser.tokenNames[clauseType.getTypeNumber()])); + } + }); + } + } + + /** + * Verify if all specified clause types are present in this map, if not than throw {@link InvalidQueryException}. + * Exception is thrown when both, negated and non-negated types are missing. + */ + private void verifyAllClausesPresence(final int... clauseTypes) + { + if (validateStrictly) + { + Arrays.stream(clauseTypes).distinct().forEach(clauseType -> { + if (!this.containsType(clauseType, false) && !this.containsType(clauseType, true)) + { + throw new InvalidQueryException(String.format(MISSING_CLAUSE_TYPE, this.name, WhereClauseParser.tokenNames[clauseType])); + } + }); + } + } + + /** + * Verify if any of specified clause types are present in this map, if not than throw {@link InvalidQueryException}. + */ + private void verifyAnyClausesPresence(final ClauseType... clauseTypes) + { + if (validateStrictly) + { + if (!this.containsAnyOfTypes(clauseTypes)) + { + throw new InvalidQueryException(String.format(MISSING_ANY_CLAUSE_OF_TYPE, + this.name, Arrays.stream(clauseTypes).map(type -> WhereClauseParser.tokenNames[type.getTypeNumber()]).collect(Collectors.toList()))); + } + } + } + + /** + * Verify if any of specified clause types are present in this map, if not than throw {@link InvalidQueryException}. + * Exception is thrown when both, negated and non-negated types are missing. + */ + private void verifyAnyClausesPresence(final int... clauseTypes) + { + if (validateStrictly) + { + final Collection expectedTypes = Arrays.stream(clauseTypes) + .distinct() + .boxed() + .flatMap(type -> Stream.of(ClauseType.of(type), ClauseType.of(type, true))) + .collect(Collectors.toSet()); + if (!this.containsAnyOfTypes(expectedTypes.toArray(ClauseType[]::new))) + { + throw new InvalidQueryException(String.format(MISSING_ANY_CLAUSE_OF_TYPE, + this.name, Arrays.stream(clauseTypes).mapToObj(type -> WhereClauseParser.tokenNames[type]).collect(Collectors.toList()))); + } + } + } + + public enum ClauseType + { + EQUALS(WhereClauseParser.EQUALS), + NOT_EQUALS(WhereClauseParser.EQUALS, true), + GREATER_THAN(WhereClauseParser.GREATERTHAN), + NOT_GREATER_THAN(WhereClauseParser.GREATERTHAN, true), + LESS_THAN(WhereClauseParser.LESSTHAN), + NOT_LESS_THAN(WhereClauseParser.LESSTHAN, true), + GREATER_THAN_OR_EQUALS(WhereClauseParser.GREATERTHANOREQUALS), + NOT_GREATER_THAN_OR_EQUALS(WhereClauseParser.GREATERTHANOREQUALS, true), + LESS_THAN_OR_EQUALS(WhereClauseParser.LESSTHANOREQUALS), + NOT_LESS_THAN_OR_EQUALS(WhereClauseParser.LESSTHANOREQUALS, true), + BETWEEN(WhereClauseParser.BETWEEN), + NOT_BETWEEN(WhereClauseParser.BETWEEN, true), + IN(WhereClauseParser.IN), + NOT_IN(WhereClauseParser.IN, true), + MATCHES(WhereClauseParser.MATCHES), + NOT_MATCHES(WhereClauseParser.MATCHES, true), + EXISTS(WhereClauseParser.EXISTS), + NOT_EXISTS(WhereClauseParser.EXISTS, true); + + private final int typeNumber; + private final boolean negated; + + ClauseType(final int typeNumber) + { + this.typeNumber = typeNumber; + this.negated = false; + } + + ClauseType(final int typeNumber, final boolean negated) + { + this.typeNumber = typeNumber; + this.negated = negated; + } + + public static ClauseType of(final int type) + { + return of(type, false); + } + + public static ClauseType of(final int type, final boolean negated) + { + return Arrays.stream(ClauseType.values()) + .filter(clauseType -> clauseType.typeNumber == type && clauseType.negated == negated) + .findFirst() + .orElseThrow(); + } + + public ClauseType negate() + { + return of(typeNumber, !negated); + } + + public int getTypeNumber() + { + return typeNumber; + } + + public boolean isNegated() + { + return negated; + } + } + + public static class NegatableValuesMap extends HashMap> + { + public Collection skipNegated() + { + return this.get(false); + } + + public Collection onlyNegated() + { + return this.get(true); + } + } + + public static class MultiTypeNegatableValuesMap extends HashMap> + { + public Map> skipNegated() + { + return this.keySet().stream() + .filter(not(ClauseType::isNegated)) + .collect(Collectors.toMap(key -> key.typeNumber, this::get)); + } + + public Collection skipNegated(final int clauseType) + { + return this.get(ClauseType.of(clauseType)); + } + + public Map> onlyNegated() + { + return this.keySet().stream() + .filter(not(ClauseType::isNegated)) + .collect(Collectors.toMap(key -> key.typeNumber, this::get)); + } + + public Collection onlyNegated(final int clauseType) + { + return this.get(ClauseType.of(clauseType, true)); + } + } +} diff --git a/remote-api/src/main/java/org/alfresco/rest/workflow/api/impl/MapBasedQueryWalker.java b/remote-api/src/main/java/org/alfresco/rest/workflow/api/impl/MapBasedQueryWalker.java index 25c565ee91..9c99843d7c 100644 --- a/remote-api/src/main/java/org/alfresco/rest/workflow/api/impl/MapBasedQueryWalker.java +++ b/remote-api/src/main/java/org/alfresco/rest/workflow/api/impl/MapBasedQueryWalker.java @@ -1,32 +1,33 @@ -/* - * #%L - * Alfresco Remote API - * %% - * Copyright (C) 2005 - 2016 Alfresco Software Limited - * %% - * This file is part of the Alfresco software. - * If the software was purchased under a paid Alfresco license, the terms of - * the paid license agreement will prevail. Otherwise, the software is - * provided under the following open source license terms: - * - * Alfresco is free software: you can redistribute it and/or modify - * it under the terms of the GNU Lesser General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * Alfresco is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Lesser General Public License for more details. - * - * You should have received a copy of the GNU Lesser General Public License - * along with Alfresco. If not, see . - * #L% - */ +/* + * #%L + * Alfresco Remote API + * %% + * Copyright (C) 2005 - 2023 Alfresco Software Limited + * %% + * This file is part of the Alfresco software. + * If the software was purchased under a paid Alfresco license, the terms of + * the paid license agreement will prevail. Otherwise, the software is + * provided under the following open source license terms: + * + * Alfresco is free software: you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Alfresco is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with Alfresco. If not, see . + * #L% + */ package org.alfresco.rest.workflow.api.impl; import java.io.Serializable; import java.util.ArrayList; +import java.util.Collection; import java.util.HashMap; import java.util.List; import java.util.Map; @@ -51,7 +52,7 @@ import org.apache.commons.beanutils.ConvertUtils; * {@link InvalidArgumentException} is thrown unless the method * {@link #handleUnmatchedComparison(int, String, String)} returns true (default * implementation returns false). - * + * * @author Frederik Heremans * @author Tijs Rademakers */ @@ -72,21 +73,21 @@ public class MapBasedQueryWalker extends WalkerCallbackAdapter private Map equalsProperties; private Map matchesProperties; - + private Map greaterThanProperties; - + private Map greaterThanOrEqualProperties; - + private Map lessThanProperties; - + private Map lessThanOrEqualProperties; - + private List variableProperties; - + private boolean variablesEnabled; - + private NamespaceService namespaceService; - + private DictionaryService dictionaryService; public MapBasedQueryWalker(Set supportedEqualsParameters, Set supportedMatchesParameters) @@ -132,7 +133,7 @@ public class MapBasedQueryWalker extends WalkerCallbackAdapter lessThanOrEqualProperties = new HashMap(); } } - + public void enableVariablesSupport(NamespaceService namespaceService, DictionaryService dictionaryService) { variablesEnabled = true; @@ -148,7 +149,7 @@ public class MapBasedQueryWalker extends WalkerCallbackAdapter this.dictionaryService = dictionaryService; variableProperties = new ArrayList(); } - + public List getVariableProperties() { return variableProperties; } @@ -158,9 +159,9 @@ public class MapBasedQueryWalker extends WalkerCallbackAdapter { if(negated) { - throw new InvalidArgumentException("Cannot use negated matching for property: " + property); + throw new InvalidArgumentException("Cannot use negated matching for property: " + property); } - if (variablesEnabled && property.startsWith("variables/")) + if (variablesEnabled && property.startsWith("variables/")) { processVariable(property, value, WhereClauseParser.MATCHES); } @@ -170,19 +171,19 @@ public class MapBasedQueryWalker extends WalkerCallbackAdapter } else { - throw new InvalidArgumentException("Cannot use matching for property: " + property); + throw new InvalidArgumentException("Cannot use matching for property: " + property); } } - + @Override public void comparison(int type, String propertyName, String propertyValue, boolean negated) { - if (variablesEnabled && propertyName.startsWith("variables/")) + if (variablesEnabled && propertyName.startsWith("variables/")) { - processVariable(propertyName, propertyValue, type); + processVariable(propertyName, propertyValue, type); return; - } - + } + boolean throwError = false; if (type == WhereClauseParser.EQUALS) { @@ -192,7 +193,7 @@ public class MapBasedQueryWalker extends WalkerCallbackAdapter } else { - throwError = !handleUnmatchedComparison(type, propertyName, propertyValue); + throwError = !handleUnmatchedComparison(type, propertyName, propertyValue); } } else if (type == WhereClauseParser.MATCHES) @@ -203,7 +204,7 @@ public class MapBasedQueryWalker extends WalkerCallbackAdapter } else { - throwError = !handleUnmatchedComparison(type, propertyName, propertyValue); + throwError = !handleUnmatchedComparison(type, propertyName, propertyValue); } } else if (type == WhereClauseParser.GREATERTHAN) @@ -214,7 +215,7 @@ public class MapBasedQueryWalker extends WalkerCallbackAdapter } else { - throwError = !handleUnmatchedComparison(type, propertyName, propertyValue); + throwError = !handleUnmatchedComparison(type, propertyName, propertyValue); } } else if (type == WhereClauseParser.GREATERTHANOREQUALS) @@ -225,7 +226,7 @@ public class MapBasedQueryWalker extends WalkerCallbackAdapter } else { - throwError = !handleUnmatchedComparison(type, propertyName, propertyValue); + throwError = !handleUnmatchedComparison(type, propertyName, propertyValue); } } else if (type == WhereClauseParser.LESSTHAN) @@ -236,7 +237,7 @@ public class MapBasedQueryWalker extends WalkerCallbackAdapter } else { - throwError = !handleUnmatchedComparison(type, propertyName, propertyValue); + throwError = !handleUnmatchedComparison(type, propertyName, propertyValue); } } else if (type == WhereClauseParser.LESSTHANOREQUALS) @@ -247,7 +248,7 @@ public class MapBasedQueryWalker extends WalkerCallbackAdapter } else { - throwError = !handleUnmatchedComparison(type, propertyName, propertyValue); + throwError = !handleUnmatchedComparison(type, propertyName, propertyValue); } } else @@ -255,15 +256,24 @@ public class MapBasedQueryWalker extends WalkerCallbackAdapter throwError = !handleUnmatchedComparison(type, propertyName, propertyValue); } - if (throwError) - { - throw new InvalidArgumentException("framework.exception.InvalidProperty", new Object[] {propertyName, propertyValue, WhereClauseParser.tokenNames[type]}); - } - else if (negated) - { - // Throw error for the unsupported negation only if the property was valid for comparison, show the more meaningful error first. - throw new InvalidArgumentException("Cannot use NOT for " + WhereClauseParser.tokenNames[type] + " comparison."); + if (throwError) + { + throw new InvalidArgumentException("framework.exception.InvalidProperty", new Object[] {propertyName, propertyValue, WhereClauseParser.tokenNames[type]}); } + else if (negated) + { + // Throw error for the unsupported negation only if the property was valid for comparison, show the more meaningful error first. + throw new InvalidArgumentException("Cannot use NOT for " + WhereClauseParser.tokenNames[type] + " comparison."); + } + } + + /** + * Get expected value for property and comparison type. This class supports only non-negated comparisons, thus parameter negated is ignored in bellow method. + */ + @Override + public Collection getProperty(String propertyName, int type, boolean negated) + { + return Set.of(this.getProperty(propertyName, type)); } public String getProperty(String propertyName, int type) @@ -300,7 +310,7 @@ public class MapBasedQueryWalker extends WalkerCallbackAdapter /** * Get the property value, converted to the requested type. - * + * * @param propertyName name of the parameter * @param type int * @param returnType type of object to return @@ -334,7 +344,7 @@ public class MapBasedQueryWalker extends WalkerCallbackAdapter { // Conversion failed, wrap in Illegal throw new InvalidArgumentException("Query property value for '" + propertyName + "' should be a valid " - + returnType.getSimpleName()); + + returnType.getSimpleName()); } } @@ -345,7 +355,7 @@ public class MapBasedQueryWalker extends WalkerCallbackAdapter // method indicates that AND is // supported. OR is not supported at the same time. } - + protected void processVariable(String propertyName, String propertyValue, int type) { String localPropertyName = propertyName.replaceFirst("variables/", ""); @@ -353,25 +363,25 @@ public class MapBasedQueryWalker extends WalkerCallbackAdapter DataTypeDefinition dataTypeDefinition = null; // variable scope global is default String scopeDef = "global"; - + // look for variable scope if (localPropertyName.contains("local/")) { scopeDef = "local"; localPropertyName = localPropertyName.replaceFirst("local/", ""); } - + if (localPropertyName.contains("global/")) { localPropertyName = localPropertyName.replaceFirst("global/", ""); } - + // look for variable type definition - if ((propertyValue.contains("_") || propertyValue.contains(":")) && propertyValue.contains(" ")) + if ((propertyValue.contains("_") || propertyValue.contains(":")) && propertyValue.contains(" ")) { int indexOfSpace = propertyValue.indexOf(' '); - if ((propertyValue.contains("_") && indexOfSpace > propertyValue.indexOf("_")) || - (propertyValue.contains(":") && indexOfSpace > propertyValue.indexOf(":"))) + if ((propertyValue.contains("_") && indexOfSpace > propertyValue.indexOf("_")) || + (propertyValue.contains(":") && indexOfSpace > propertyValue.indexOf(":"))) { String typeDef = propertyValue.substring(0, indexOfSpace); try @@ -386,7 +396,7 @@ public class MapBasedQueryWalker extends WalkerCallbackAdapter } } } - + if (dataTypeDefinition != null && "java.util.Date".equalsIgnoreCase(dataTypeDefinition.getJavaClassName())) { // fix for different ISO 8601 Date format classes in Alfresco (org.alfresco.util and Spring Surf) @@ -396,18 +406,18 @@ public class MapBasedQueryWalker extends WalkerCallbackAdapter { actualValue = DefaultTypeConverter.INSTANCE.convert(dataTypeDefinition, propertyValue); } - else + else { actualValue = propertyValue; } - + variableProperties.add(new QueryVariableHolder(localPropertyName, type, actualValue, scopeDef)); } /** * Called when unsupported property is encountered or comparison operator * other than equals. - * + * * @return true, if the comparison is handles successfully. False, if an * exception should be thrown because the comparison can't be * handled. @@ -416,25 +426,25 @@ public class MapBasedQueryWalker extends WalkerCallbackAdapter { return false; } - + public static class QueryVariableHolder implements Serializable { private static final long serialVersionUID = 1L; - + private String propertyName; private int operator; private Object propertyValue; private String scope; - + public QueryVariableHolder() {} - + public QueryVariableHolder(String propertyName, int operator, Object propertyValue, String scope) { this.propertyName = propertyName; this.operator = operator; this.propertyValue = propertyValue; this.scope = scope; } - + public String getPropertyName() { return propertyName; diff --git a/remote-api/src/test/java/org/alfresco/rest/api/impl/TagsImplTest.java b/remote-api/src/test/java/org/alfresco/rest/api/impl/TagsImplTest.java index da9cc933a3..16ccb05454 100644 --- a/remote-api/src/test/java/org/alfresco/rest/api/impl/TagsImplTest.java +++ b/remote-api/src/test/java/org/alfresco/rest/api/impl/TagsImplTest.java @@ -27,24 +27,34 @@ package org.alfresco.rest.api.impl; import static org.alfresco.rest.api.impl.TagsImpl.NOT_A_VALID_TAG; import static org.alfresco.rest.api.impl.TagsImpl.NO_PERMISSION_TO_MANAGE_A_TAG; +import static org.alfresco.service.cmr.repository.StoreRef.STORE_REF_WORKSPACE_SPACESSTORE; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.catchThrowable; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertThrows; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.isNull; import static org.mockito.BDDMockito.given; import static org.mockito.BDDMockito.then; import java.util.Collections; import java.util.List; +import java.util.Set; import java.util.stream.Collectors; +import org.alfresco.query.PagingRequest; +import org.alfresco.query.PagingResults; import org.alfresco.rest.api.Nodes; import org.alfresco.rest.api.model.Tag; import org.alfresco.rest.framework.core.exceptions.EntityNotFoundException; import org.alfresco.rest.framework.core.exceptions.InvalidArgumentException; import org.alfresco.rest.framework.core.exceptions.PermissionDeniedException; +import org.alfresco.rest.framework.resource.parameters.CollectionWithPagingInfo; +import org.alfresco.rest.framework.resource.parameters.Paging; import org.alfresco.rest.framework.resource.parameters.Parameters; +import org.alfresco.rest.framework.resource.parameters.where.InvalidQueryException; +import org.alfresco.rest.framework.tools.RecognizedParamsExtractor; import org.alfresco.service.cmr.repository.DuplicateChildNodeNameException; import org.alfresco.service.cmr.repository.NodeRef; import org.alfresco.service.cmr.repository.StoreRef; @@ -63,7 +73,9 @@ public class TagsImplTest { private static final String TAG_ID = "tag-node-id"; private static final String TAG_NAME = "tag-dummy-name"; - private static final NodeRef TAG_NODE_REF = new NodeRef(StoreRef.STORE_REF_WORKSPACE_SPACESSTORE, TAG_ID); + private static final NodeRef TAG_NODE_REF = new NodeRef(STORE_REF_WORKSPACE_SPACESSTORE, TAG_ID.concat("-").concat(TAG_NAME)); + + private final RecognizedParamsExtractor queryExtractor = new RecognizedParamsExtractor() {}; @Mock private Nodes nodesMock; @@ -73,6 +85,10 @@ public class TagsImplTest private TaggingService taggingServiceMock; @Mock private Parameters parametersMock; + @Mock + private Paging pagingMock; + @Mock + private PagingResults> pagingResultsMock; @InjectMocks private TagsImpl objectUnderTest; @@ -81,36 +97,145 @@ public class TagsImplTest public void setup() { given(authorityServiceMock.hasAdminAuthority()).willReturn(true); - given(nodesMock.validateNode(StoreRef.STORE_REF_WORKSPACE_SPACESSTORE, TAG_ID)).willReturn(TAG_NODE_REF); + given(nodesMock.validateNode(STORE_REF_WORKSPACE_SPACESSTORE, TAG_ID)).willReturn(TAG_NODE_REF); given(taggingServiceMock.getTagName(TAG_NODE_REF)).willReturn(TAG_NAME); } + @Test - public void testGetTags() { - final List tagNames = List.of("testTag","tag11"); - final List tagsToCreate = createTags(tagNames); - given(taggingServiceMock.createTags(any(), any())).willAnswer(invocation -> createTagAndNodeRefPairs(invocation.getArgument(1))); + public void testGetTags() + { + given(parametersMock.getPaging()).willReturn(pagingMock); + given(taggingServiceMock.getTags(any(StoreRef.class), any(PagingRequest.class), any(), any())).willReturn(pagingResultsMock); + given(pagingResultsMock.getTotalResultCount()).willReturn(new Pair<>(Integer.MAX_VALUE, 0)); + given(pagingResultsMock.getPage()).willReturn(List.of(new Pair<>(TAG_NODE_REF, TAG_NAME))); + + final CollectionWithPagingInfo actualTags = objectUnderTest.getTags(STORE_REF_WORKSPACE_SPACESSTORE, parametersMock); + + then(taggingServiceMock).should().getTags(eq(STORE_REF_WORKSPACE_SPACESSTORE), any(PagingRequest.class), isNull(), isNull()); + then(taggingServiceMock).shouldHaveNoMoreInteractions(); + final List expectedTags = createTagsWithNodeRefs(List.of(TAG_NAME)).stream().peek(tag -> tag.setCount(0)).collect(Collectors.toList()); + assertEquals(expectedTags, actualTags.getCollection()); + } + + @Test + public void testGetTags_verifyIfCountIsZero() + { + given(parametersMock.getPaging()).willReturn(pagingMock); + given(taggingServiceMock.getTags(any(StoreRef.class), any(PagingRequest.class), any(), any())).willReturn(pagingResultsMock); + given(pagingResultsMock.getTotalResultCount()).willReturn(new Pair<>(Integer.MAX_VALUE, 0)); + given(pagingResultsMock.getPage()).willReturn(List.of(new Pair<>(TAG_NODE_REF, TAG_NAME))); given(parametersMock.getInclude()).willReturn(List.of("count")); - final List actualCreatedTags = objectUnderTest.createTags(tagsToCreate, parametersMock); - final List expectedTags = createTagsWithNodeRefs(tagNames).stream() + + final CollectionWithPagingInfo actualTags = objectUnderTest.getTags(STORE_REF_WORKSPACE_SPACESSTORE, parametersMock); + + then(taggingServiceMock).should().findTaggedNodesAndCountByTagName(STORE_REF_WORKSPACE_SPACESSTORE); + final List expectedTags = createTagsWithNodeRefs(List.of(TAG_NAME)).stream() .peek(tag -> tag.setCount(0)) .collect(Collectors.toList()); - assertEquals(expectedTags, actualCreatedTags); + assertEquals(expectedTags, actualTags.getCollection()); + } + + @Test + public void testGetTags_withEqualsClauseWhereQuery() + { + given(parametersMock.getPaging()).willReturn(pagingMock); + given(parametersMock.getQuery()).willReturn(queryExtractor.getWhereClause("(tag=expectedName)")); + given(taggingServiceMock.getTags(any(StoreRef.class), any(PagingRequest.class), any(), any())).willReturn(pagingResultsMock); + given(pagingResultsMock.getTotalResultCount()).willReturn(new Pair<>(Integer.MAX_VALUE, 0)); + + //when + final CollectionWithPagingInfo actualTags = objectUnderTest.getTags(STORE_REF_WORKSPACE_SPACESSTORE, parametersMock); + + then(taggingServiceMock).should().getTags(eq(STORE_REF_WORKSPACE_SPACESSTORE), any(PagingRequest.class), eq(Set.of("expectedname")), isNull()); + then(taggingServiceMock).shouldHaveNoMoreInteractions(); + assertThat(actualTags).isNotNull(); + } + + @Test + public void testGetTags_withInClauseWhereQuery() + { + given(parametersMock.getPaging()).willReturn(pagingMock); + given(parametersMock.getQuery()).willReturn(queryExtractor.getWhereClause("(tag IN (expectedName1, expectedName2))")); + given(taggingServiceMock.getTags(any(StoreRef.class), any(PagingRequest.class), any(), any())).willReturn(pagingResultsMock); + given(pagingResultsMock.getTotalResultCount()).willReturn(new Pair<>(Integer.MAX_VALUE, 0)); + + //when + final CollectionWithPagingInfo actualTags = objectUnderTest.getTags(STORE_REF_WORKSPACE_SPACESSTORE, parametersMock); + + then(taggingServiceMock).should().getTags(eq(STORE_REF_WORKSPACE_SPACESSTORE), any(PagingRequest.class), eq(Set.of("expectedname1", "expectedname2")), isNull()); + then(taggingServiceMock).shouldHaveNoMoreInteractions(); + assertThat(actualTags).isNotNull(); + } + + @Test + public void testGetTags_withMatchesClauseWhereQuery() + { + given(parametersMock.getPaging()).willReturn(pagingMock); + given(parametersMock.getQuery()).willReturn(queryExtractor.getWhereClause("(tag MATCHES ('expectedName*'))")); + given(taggingServiceMock.getTags(any(StoreRef.class), any(PagingRequest.class), any(), any())).willReturn(pagingResultsMock); + given(pagingResultsMock.getTotalResultCount()).willReturn(new Pair<>(Integer.MAX_VALUE, 0)); + + //when + final CollectionWithPagingInfo actualTags = objectUnderTest.getTags(STORE_REF_WORKSPACE_SPACESSTORE, parametersMock); + + then(taggingServiceMock).should().getTags(eq(STORE_REF_WORKSPACE_SPACESSTORE), any(PagingRequest.class), isNull(), eq(Set.of("expectedname*"))); + then(taggingServiceMock).shouldHaveNoMoreInteractions(); + assertThat(actualTags).isNotNull(); + } + + @Test + public void testGetTags_withBothInAndEqualsClausesInSingleWhereQuery() + { + given(parametersMock.getPaging()).willReturn(pagingMock); + given(parametersMock.getQuery()).willReturn(queryExtractor.getWhereClause("(tag=expectedName AND tag IN (expectedName1, expectedName2))")); + + //when + final Throwable actualException = catchThrowable(() -> objectUnderTest.getTags(STORE_REF_WORKSPACE_SPACESSTORE, parametersMock)); + + then(taggingServiceMock).shouldHaveNoInteractions(); + assertThat(actualException).isInstanceOf(InvalidQueryException.class); + } + + @Test + public void testGetTags_withOtherClauseInWhereQuery() + { + given(parametersMock.getPaging()).willReturn(pagingMock); + given(parametersMock.getQuery()).willReturn(queryExtractor.getWhereClause("(tag BETWEEN ('expectedName', 'expectedName2'))")); + + //when + final Throwable actualException = catchThrowable(() -> objectUnderTest.getTags(STORE_REF_WORKSPACE_SPACESSTORE, parametersMock)); + + then(taggingServiceMock).shouldHaveNoInteractions(); + assertThat(actualException).isInstanceOf(InvalidQueryException.class); + } + + @Test + public void testGetTags_withNotEqualsClauseInWhereQuery() + { + given(parametersMock.getPaging()).willReturn(pagingMock); + given(parametersMock.getQuery()).willReturn(queryExtractor.getWhereClause("(NOT tag=expectedName)")); + + //when + final Throwable actualException = catchThrowable(() -> objectUnderTest.getTags(STORE_REF_WORKSPACE_SPACESSTORE, parametersMock)); + + then(taggingServiceMock).shouldHaveNoInteractions(); + assertThat(actualException).isInstanceOf(InvalidQueryException.class); } @Test public void testDeleteTagById() { //when - objectUnderTest.deleteTagById(StoreRef.STORE_REF_WORKSPACE_SPACESSTORE, TAG_ID); + objectUnderTest.deleteTagById(STORE_REF_WORKSPACE_SPACESSTORE, TAG_ID); then(authorityServiceMock).should().hasAdminAuthority(); then(authorityServiceMock).shouldHaveNoMoreInteractions(); - then(nodesMock).should().validateNode(StoreRef.STORE_REF_WORKSPACE_SPACESSTORE, TAG_ID); + then(nodesMock).should().validateNode(STORE_REF_WORKSPACE_SPACESSTORE, TAG_ID); then(nodesMock).shouldHaveNoMoreInteractions(); then(taggingServiceMock).should().getTagName(TAG_NODE_REF); - then(taggingServiceMock).should().deleteTag(StoreRef.STORE_REF_WORKSPACE_SPACESSTORE, TAG_NAME); + then(taggingServiceMock).should().deleteTag(STORE_REF_WORKSPACE_SPACESSTORE, TAG_NAME); then(taggingServiceMock).shouldHaveNoMoreInteractions(); } @@ -120,7 +245,7 @@ public class TagsImplTest given(authorityServiceMock.hasAdminAuthority()).willReturn(false); //when - assertThrows(PermissionDeniedException.class, () -> objectUnderTest.deleteTagById(StoreRef.STORE_REF_WORKSPACE_SPACESSTORE, TAG_ID)); + assertThrows(PermissionDeniedException.class, () -> objectUnderTest.deleteTagById(STORE_REF_WORKSPACE_SPACESSTORE, TAG_ID)); then(authorityServiceMock).should().hasAdminAuthority(); then(authorityServiceMock).shouldHaveNoMoreInteractions(); @@ -134,12 +259,12 @@ public class TagsImplTest public void testDeleteTagById_nonExistentTag() { //when - assertThrows(EntityNotFoundException.class, () -> objectUnderTest.deleteTagById(StoreRef.STORE_REF_WORKSPACE_SPACESSTORE, "dummy-id")); + assertThrows(EntityNotFoundException.class, () -> objectUnderTest.deleteTagById(STORE_REF_WORKSPACE_SPACESSTORE, "dummy-id")); then(authorityServiceMock).should().hasAdminAuthority(); then(authorityServiceMock).shouldHaveNoMoreInteractions(); - then(nodesMock).should().validateNode(StoreRef.STORE_REF_WORKSPACE_SPACESSTORE, "dummy-id"); + then(nodesMock).should().validateNode(STORE_REF_WORKSPACE_SPACESSTORE, "dummy-id"); then(nodesMock).shouldHaveNoMoreInteractions(); then(taggingServiceMock).shouldHaveNoInteractions(); @@ -157,11 +282,11 @@ public class TagsImplTest then(authorityServiceMock).should().hasAdminAuthority(); then(authorityServiceMock).shouldHaveNoMoreInteractions(); - then(taggingServiceMock).should().createTags(StoreRef.STORE_REF_WORKSPACE_SPACESSTORE, tagNames); + then(taggingServiceMock).should().createTags(STORE_REF_WORKSPACE_SPACESSTORE, tagNames); then(taggingServiceMock).shouldHaveNoMoreInteractions(); final List expectedTags = createTagsWithNodeRefs(tagNames); assertThat(actualCreatedTags) - .isNotNull() + .isNotNull().usingRecursiveComparison() .isEqualTo(expectedTags); } @@ -225,7 +350,7 @@ public class TagsImplTest //when final Throwable actualException = catchThrowable(() -> objectUnderTest.createTags(List.of(createTag(TAG_NAME)), parametersMock)); - then(taggingServiceMock).should().createTags(StoreRef.STORE_REF_WORKSPACE_SPACESSTORE, List.of(TAG_NAME)); + then(taggingServiceMock).should().createTags(STORE_REF_WORKSPACE_SPACESSTORE, List.of(TAG_NAME)); then(taggingServiceMock).shouldHaveNoMoreInteractions(); assertThat(actualException).isInstanceOf(DuplicateChildNodeNameException.class); } @@ -240,7 +365,7 @@ public class TagsImplTest //when final List actualCreatedTags = objectUnderTest.createTags(tagsToCreate, parametersMock); - then(taggingServiceMock).should().createTags(StoreRef.STORE_REF_WORKSPACE_SPACESSTORE, List.of(TAG_NAME)); + then(taggingServiceMock).should().createTags(STORE_REF_WORKSPACE_SPACESSTORE, List.of(TAG_NAME)); final List expectedTags = List.of(createTagWithNodeRef(TAG_NAME)); assertThat(actualCreatedTags) .isNotNull() @@ -269,7 +394,7 @@ public class TagsImplTest private static List> createTagAndNodeRefPairs(final List tagNames) { return tagNames.stream() - .map(tagName -> createPair(tagName, new NodeRef(StoreRef.STORE_REF_WORKSPACE_SPACESSTORE, TAG_ID.concat("-").concat(tagName)))) + .map(tagName -> createPair(tagName, new NodeRef(STORE_REF_WORKSPACE_SPACESSTORE, TAG_ID.concat("-").concat(tagName)))) .collect(Collectors.toList()); } @@ -298,7 +423,7 @@ public class TagsImplTest private static Tag createTagWithNodeRef(final String tagName) { return Tag.builder() - .nodeRef(new NodeRef(StoreRef.STORE_REF_WORKSPACE_SPACESSTORE, TAG_ID.concat("-").concat(tagName))) + .nodeRef(new NodeRef(STORE_REF_WORKSPACE_SPACESSTORE, TAG_ID.concat("-").concat(tagName))) .tag(tagName) .create(); } diff --git a/remote-api/src/test/java/org/alfresco/rest/framework/resource/parameters/where/QueryResolverTest.java b/remote-api/src/test/java/org/alfresco/rest/framework/resource/parameters/where/QueryResolverTest.java new file mode 100644 index 0000000000..f0c0c800b0 --- /dev/null +++ b/remote-api/src/test/java/org/alfresco/rest/framework/resource/parameters/where/QueryResolverTest.java @@ -0,0 +1,666 @@ +/* + * #%L + * Alfresco Remote API + * %% + * Copyright (C) 2005 - 2023 Alfresco Software Limited + * %% + * This file is part of the Alfresco software. + * If the software was purchased under a paid Alfresco license, the terms of + * the paid license agreement will prevail. Otherwise, the software is + * provided under the following open source license terms: + * + * Alfresco is free software: you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Alfresco is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with Alfresco. If not, see . + * #L% + */ +package org.alfresco.rest.framework.resource.parameters.where; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatExceptionOfType; +import static org.assertj.core.api.Assertions.catchThrowable; + +import java.util.Collection; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import org.alfresco.rest.antlr.WhereClauseParser; +import org.alfresco.rest.framework.tools.RecognizedParamsExtractor; +import org.alfresco.rest.workflow.api.impl.MapBasedQueryWalker; +import org.junit.Test; + +/** + * Tests verifying {@link QueryHelper.QueryResolver} functionality based on {@link BasicQueryWalker}. + */ +public class QueryResolverTest +{ + private final RecognizedParamsExtractor queryExtractor = new RecognizedParamsExtractor() {}; + + @Test + public void testResolveQuery_equals() + { + final Query query = queryExtractor.getWhereClause("(propName=testValue)"); + + //when + final WhereProperty property = QueryHelper.resolve(query).getProperty("propName"); + + assertThat(property.containsType(WhereClauseParser.EQUALS, false)).isTrue(); + assertThat(property.containsType(WhereClauseParser.GREATERTHAN, false)).isFalse(); + assertThat(property.containsType(WhereClauseParser.LESSTHAN, false)).isFalse(); + assertThat(property.containsType(WhereClauseParser.GREATERTHANOREQUALS, false)).isFalse(); + assertThat(property.containsType(WhereClauseParser.LESSTHANOREQUALS, false)).isFalse(); + assertThat(property.containsType(WhereClauseParser.IN, false)).isFalse(); + assertThat(property.containsType(WhereClauseParser.MATCHES, false)).isFalse(); + assertThat(property.containsType(WhereClauseParser.BETWEEN, false)).isFalse(); + assertThat(property.containsType(WhereClauseParser.EXISTS, false)).isFalse(); + assertThat(property.containsType(WhereClauseParser.EQUALS, true)).isFalse(); + assertThat(property.containsType(WhereClauseParser.GREATERTHAN, true)).isFalse(); + assertThat(property.containsType(WhereClauseParser.LESSTHAN, true)).isFalse(); + assertThat(property.containsType(WhereClauseParser.GREATERTHANOREQUALS, true)).isFalse(); + assertThat(property.containsType(WhereClauseParser.LESSTHANOREQUALS, true)).isFalse(); + assertThat(property.containsType(WhereClauseParser.IN, true)).isFalse(); + assertThat(property.containsType(WhereClauseParser.MATCHES, true)).isFalse(); + assertThat(property.containsType(WhereClauseParser.BETWEEN, true)).isFalse(); + assertThat(property.containsType(WhereClauseParser.EXISTS, true)).isFalse(); + assertThat(property.getExpectedValuesFor(WhereClauseParser.EQUALS, false)).containsOnly("testValue"); + } + + @Test + public void testResolveQuery_greaterThan() + { + final Query query = queryExtractor.getWhereClause("(propName > testValue)"); + + //when + final WhereProperty property = QueryHelper.resolve(query).getProperty("propName"); + + assertThat(property.containsType(WhereClauseParser.GREATERTHAN, false)).isTrue(); + assertThat(property.getExpectedValuesFor(WhereClauseParser.GREATERTHAN, false)).containsOnly("testValue"); + } + + @Test + public void testResolveQuery_greaterThanOrEquals() + { + final Query query = queryExtractor.getWhereClause("(propName >= testValue)"); + + //when + final WhereProperty property = QueryHelper.resolve(query).getProperty("propName"); + + assertThat(property.containsType(WhereClauseParser.GREATERTHANOREQUALS, false)).isTrue(); + assertThat(property.getExpectedValuesFor(WhereClauseParser.GREATERTHANOREQUALS, false)).containsOnly("testValue"); + } + + @Test + public void testResolveQuery_lessThan() + { + final Query query = queryExtractor.getWhereClause("(propName < testValue)"); + + //when + final WhereProperty property = QueryHelper.resolve(query).getProperty("propName"); + + assertThat(property.containsType(WhereClauseParser.LESSTHAN, false)).isTrue(); + assertThat(property.getExpectedValuesFor(WhereClauseParser.LESSTHAN, false)).containsOnly("testValue"); + } + + @Test + public void testResolveQuery_lessThanOrEquals() + { + final Query query = queryExtractor.getWhereClause("(propName <= testValue)"); + + //when + final WhereProperty property = QueryHelper.resolve(query).getProperty("propName"); + + assertThat(property.containsType(WhereClauseParser.LESSTHANOREQUALS, false)).isTrue(); + assertThat(property.getExpectedValuesFor(WhereClauseParser.LESSTHANOREQUALS, false)).containsOnly("testValue"); + } + + @Test + public void testResolveQuery_between() + { + final Query query = queryExtractor.getWhereClause("(propName BETWEEN (testValue, testValue2))"); + + //when + final WhereProperty property = QueryHelper.resolve(query).getProperty("propName"); + + assertThat(property.containsType(WhereClauseParser.BETWEEN, false)).isTrue(); + assertThat(property.getExpectedValuesFor(WhereClauseParser.BETWEEN, false)).containsOnly("testValue", "testValue2"); + } + + @Test + public void testResolveQuery_in() + { + final Query query = queryExtractor.getWhereClause("(propName IN (testValue, testValue2))"); + + //when + final WhereProperty property = QueryHelper.resolve(query).getProperty("propName"); + + assertThat(property.containsType(WhereClauseParser.IN, false)).isTrue(); + assertThat(property.getExpectedValuesFor(WhereClauseParser.IN, false)).containsOnly("testValue", "testValue2"); + } + + @Test + public void testResolveQuery_matches() + { + final Query query = queryExtractor.getWhereClause("(propName MATCHES ('*Value'))"); + + //when + final WhereProperty property = QueryHelper.resolve(query).getProperty("propName"); + + assertThat(property.containsType(WhereClauseParser.MATCHES, false)).isTrue(); + assertThat(property.getExpectedValuesFor(WhereClauseParser.MATCHES, false)).containsOnly("*Value"); + } + + @Test + public void testResolveQuery_exists() + { + final Query query = queryExtractor.getWhereClause("(EXISTS (propName))"); + + //when + final WhereProperty property = QueryHelper.resolve(query).getProperty("propName"); + + assertThat(property.containsType(WhereClauseParser.EXISTS, false)).isTrue(); + assertThat(property.getExpectedValuesFor(WhereClauseParser.EXISTS, false)).isEmpty(); + } + + @Test + public void testResolveQuery_notEquals() + { + final Query query = queryExtractor.getWhereClause("(NOT propName=testValue)"); + + //when + final WhereProperty property = QueryHelper.resolve(query).getProperty("propName"); + + assertThat(property.containsType(WhereClauseParser.EQUALS, true)).isTrue(); + assertThat(property.containsType(WhereClauseParser.EQUALS, false)).isFalse(); + assertThat(property.containsType(WhereClauseParser.GREATERTHAN, false)).isFalse(); + assertThat(property.containsType(WhereClauseParser.LESSTHAN, false)).isFalse(); + assertThat(property.containsType(WhereClauseParser.GREATERTHANOREQUALS, false)).isFalse(); + assertThat(property.containsType(WhereClauseParser.LESSTHANOREQUALS, false)).isFalse(); + assertThat(property.containsType(WhereClauseParser.IN, false)).isFalse(); + assertThat(property.containsType(WhereClauseParser.MATCHES, false)).isFalse(); + assertThat(property.containsType(WhereClauseParser.BETWEEN, false)).isFalse(); + assertThat(property.containsType(WhereClauseParser.EXISTS, false)).isFalse(); + assertThat(property.containsType(WhereClauseParser.GREATERTHAN, true)).isFalse(); + assertThat(property.containsType(WhereClauseParser.LESSTHAN, true)).isFalse(); + assertThat(property.containsType(WhereClauseParser.GREATERTHANOREQUALS, true)).isFalse(); + assertThat(property.containsType(WhereClauseParser.LESSTHANOREQUALS, true)).isFalse(); + assertThat(property.containsType(WhereClauseParser.IN, true)).isFalse(); + assertThat(property.containsType(WhereClauseParser.MATCHES, true)).isFalse(); + assertThat(property.containsType(WhereClauseParser.BETWEEN, true)).isFalse(); + assertThat(property.containsType(WhereClauseParser.EXISTS, true)).isFalse(); + assertThat(property.getExpectedValuesFor(WhereClauseParser.EQUALS, true)).containsOnly("testValue"); + } + + @Test + public void testResolveQuery_notGreaterThan() + { + final Query query = queryExtractor.getWhereClause("(NOT propName > testValue)"); + + //when + final WhereProperty property = QueryHelper.resolve(query).getProperty("propName"); + + assertThat(property.containsType(WhereClauseParser.GREATERTHAN, true)).isTrue(); + assertThat(property.getExpectedValuesFor(WhereClauseParser.GREATERTHAN, true)).containsOnly("testValue"); + } + + @Test + public void testResolveQuery_notGreaterThanOrEquals() + { + final Query query = queryExtractor.getWhereClause("(NOT propName >= testValue)"); + + //when + final WhereProperty property = QueryHelper.resolve(query).getProperty("propName"); + + assertThat(property.containsType(WhereClauseParser.GREATERTHANOREQUALS, true)).isTrue(); + assertThat(property.getExpectedValuesFor(WhereClauseParser.GREATERTHANOREQUALS, true)).containsOnly("testValue"); + } + + @Test + public void testResolveQuery_notLessThan() + { + final Query query = queryExtractor.getWhereClause("(NOT propName < testValue)"); + + //when + final WhereProperty property = QueryHelper.resolve(query).getProperty("propName"); + + assertThat(property.containsType(WhereClauseParser.LESSTHAN, true)).isTrue(); + assertThat(property.getExpectedValuesFor(WhereClauseParser.LESSTHAN, true)).containsOnly("testValue"); + } + + @Test + public void testResolveQuery_notLessThanOrEquals() + { + final Query query = queryExtractor.getWhereClause("(NOT propName <= testValue)"); + + //when + final WhereProperty property = QueryHelper.resolve(query).getProperty("propName"); + + assertThat(property.containsType(WhereClauseParser.LESSTHANOREQUALS, true)).isTrue(); + assertThat(property.getExpectedValuesFor(WhereClauseParser.LESSTHANOREQUALS, true)).containsOnly("testValue"); + } + + @Test + public void testResolveQuery_notBetween() + { + final Query query = queryExtractor.getWhereClause("(NOT propName BETWEEN (testValue, testValue2))"); + + //when + final WhereProperty property = QueryHelper.resolve(query).getProperty("propName"); + + assertThat(property.containsType(WhereClauseParser.BETWEEN, true)).isTrue(); + assertThat(property.getExpectedValuesFor(WhereClauseParser.BETWEEN, true)).containsOnly("testValue", "testValue2"); + } + + @Test + public void testResolveQuery_notIn() + { + final Query query = queryExtractor.getWhereClause("(NOT propName IN (testValue, testValue2))"); + + //when + final WhereProperty property = QueryHelper.resolve(query).getProperty("propName"); + + assertThat(property.containsType(WhereClauseParser.IN, true)).isTrue(); + assertThat(property.getExpectedValuesFor(WhereClauseParser.IN, true)).containsOnly("testValue", "testValue2"); + } + + @Test + public void testResolveQuery_notMatches() + { + final Query query = queryExtractor.getWhereClause("(NOT propName MATCHES ('*Value'))"); + + //when + final WhereProperty property = QueryHelper.resolve(query).getProperty("propName"); + + assertThat(property.containsType(WhereClauseParser.MATCHES, true)).isTrue(); + assertThat(property.getExpectedValuesFor(WhereClauseParser.MATCHES, true)).containsOnly("*Value"); + } + + @Test + public void testResolveQuery_notExists() + { + final Query query = queryExtractor.getWhereClause("(NOT EXISTS (propName))"); + + //when + final WhereProperty property = QueryHelper.resolve(query).getProperty("propName"); + + assertThat(property.containsType(WhereClauseParser.EXISTS, true)).isTrue(); + assertThat(property.getExpectedValuesFor(WhereClauseParser.EXISTS, true)).isEmpty(); + } + + @Test + public void testResolveQuery_propertyNotExpected() + { + final Query query = queryExtractor.getWhereClause("(propName=testValue AND differentName>18)"); + + //when + final Throwable actualException = catchThrowable(() -> QueryHelper.resolve(query).getProperty("differentName")); + + assertThat(actualException).isInstanceOf(InvalidQueryException.class); + } + + @Test + public void testResolveQuery_propertyNotExpectedUsingLenientApproach() + { + final Query query = queryExtractor.getWhereClause("(propName=testValue AND differentName>18)"); + + //when + final WhereProperty property = QueryHelper.resolve(query).leniently().getProperty("differentName"); + + assertThat(property.containsType(WhereClauseParser.EQUALS, false)).isFalse(); + assertThat(property.containsType(WhereClauseParser.EQUALS, true)).isFalse(); + assertThat(property.getExpectedValuesFor(WhereClauseParser.EQUALS, false)).isNull(); + assertThat(property.getExpectedValuesFor(WhereClauseParser.EQUALS, true)).isNull(); + assertThat(property.containsType(WhereClauseParser.GREATERTHAN, false)).isTrue(); + assertThat(property.getExpectedValuesFor(WhereClauseParser.GREATERTHAN, false)).containsOnly("18"); + } + + @Test + public void testResolveQuery_propertyNotPresentUsingLenientApproach() + { + final Query query = queryExtractor.getWhereClause("(propName=testValue)"); + + //when + final Throwable actualException = catchThrowable(() -> QueryHelper.resolve(query).getProperty("differentName")); + + assertThat(actualException).isInstanceOf(InvalidQueryException.class); + } + + @Test + public void testResolveQuery_slashInPropertyName() + { + final Query query = queryExtractor.getWhereClause("(EXISTS (prop/name/with/slashes))"); + + //when + final WhereProperty property = QueryHelper.resolve(query).getProperty("prop/name/with/slashes"); + + assertThat(property.containsType(WhereClauseParser.EXISTS, false)).isTrue(); + assertThat(property.getExpectedValuesFor(WhereClauseParser.EXISTS, false)).isEmpty(); + } + + @Test + public void testResolveQuery_propertyBetweenDates() + { + final Query query = queryExtractor.getWhereClause("(propName BETWEEN ('2012-01-01', '2012-12-31'))"); + + //when + final WhereProperty property = QueryHelper.resolve(query).getProperty("propName"); + + assertThat(property.containsType(WhereClauseParser.BETWEEN, false)).isTrue(); + assertThat(property.getExpectedValuesFor(WhereClauseParser.BETWEEN, false)).containsOnly("2012-01-01", "2012-12-31"); + } + + @Test + public void testResolveQuery_singlePropertyGreaterThanOrEqualsAndLessThan() + { + final Query query = queryExtractor.getWhereClause("(propName >= 18 AND propName < 65)"); + + //when + final WhereProperty property = QueryHelper.resolve(query).getProperty("propName"); + + assertThat(property.containsType(WhereClauseParser.GREATERTHANOREQUALS, false)).isTrue(); + assertThat(property.getExpectedValuesFor(WhereClauseParser.GREATERTHANOREQUALS, false)).containsOnly("18"); + assertThat(property.containsType(WhereClauseParser.LESSTHAN, false)).isTrue(); + assertThat(property.getExpectedValuesFor(WhereClauseParser.LESSTHAN, false)).containsOnly("65"); + } + + @Test + public void testResolveQuery_onePropertyGreaterThanAndSecondPropertyNotMatches() + { + final Query query = queryExtractor.getWhereClause("(propName1 > 20 AND NOT propName2 MATCHES ('external*'))"); + + //when + final List property = QueryHelper.resolve(query).getProperties("propName1", "propName2"); + + assertThat(property.get(0).containsType(WhereClauseParser.GREATERTHAN, false)).isTrue(); + assertThat(property.get(0).getExpectedValuesFor(WhereClauseParser.GREATERTHAN, false)).containsOnly("20"); + assertThat(property.get(1).containsType(WhereClauseParser.MATCHES, true)).isTrue(); + assertThat(property.get(1).getExpectedValuesFor(WhereClauseParser.MATCHES, true)).containsOnly("external*"); + } + + @Test + public void testResolveQuery_negationsForbidden() + { + final Query query = queryExtractor.getWhereClause("(NOT propName=testValue)"); + + //when + final Throwable actualException = catchThrowable(() -> QueryHelper.resolve(query).withoutNegations().getProperty("propName")); + + assertThat(actualException).isInstanceOf(InvalidQueryException.class); + } + + @Test + public void testResolveQuery_withoutNegations() + { + final Query query = queryExtractor.getWhereClause("(propName=testValue)"); + + //when + final WhereProperty actualProperty = QueryHelper.resolve(query).withoutNegations().getProperty("propName"); + + assertThat(actualProperty.containsType(WhereClauseParser.EQUALS, false)).isTrue(); + assertThat(actualProperty.containsType(WhereClauseParser.EQUALS, true)).isFalse(); + assertThat(actualProperty.getExpectedValuesFor(WhereClauseParser.EQUALS).onlyNegated()).isNull(); + assertThat(actualProperty.getExpectedValuesFor(WhereClauseParser.EQUALS).skipNegated()).containsOnly("testValue"); + } + + @Test + public void testResolveQuery_orNotAllowed() + { + final Query query = queryExtractor.getWhereClause("(propName=testValue OR propName BETWEEN (testValue2, testValue3))"); + + //when + final Throwable actualException = catchThrowable(() -> QueryHelper.resolve(query).getProperty("propName")); + + assertThat(actualException).isInstanceOf(InvalidQueryException.class); + } + + @Test + public void testResolveQuery_orAllowedInFavorOfAnd() + { + final Query query = queryExtractor.getWhereClause("(propName=testValue OR propName=testValue2)"); + + //when + final WhereProperty property = QueryHelper + .resolve(query) + .usingOrOperator() + .getProperty("propName"); + + assertThat(property.containsType(WhereClauseParser.EQUALS, false)).isTrue(); + assertThat(property.getExpectedValuesFor(WhereClauseParser.EQUALS, false)).containsOnly("testValue", "testValue2"); + } + + @Test + public void testResolveQuery_usingCustomQueryWalker() + { + final Query query = queryExtractor.getWhereClause("(propName=testValue)"); + + //when + final Collection propertyValues = QueryHelper + .resolve(query) + .usingWalker(new MapBasedQueryWalker(Set.of("propName"), null)) + .getProperty("propName", WhereClauseParser.EQUALS, false); + + assertThat(propertyValues).containsOnly("testValue"); + } + + @Test + public void testResolveQuery_usingCustomBasicQueryWalkerExtension() + { + final Query query = queryExtractor.getWhereClause("(propName=testValue OR propName=testValue2)"); + + //when + final WhereProperty property = QueryHelper + .resolve(query) + .usingWalker(new BasicQueryWalker("propName") + { + @Override + public void or() {} + @Override + public void and() {throw UNSUPPORTED;} + }) + .withoutNegations() + .getProperty("propName"); + + assertThat(property.containsType(WhereClauseParser.EQUALS, false)).isTrue(); + assertThat(property.getExpectedValuesFor(WhereClauseParser.EQUALS, false)).containsOnly("testValue", "testValue2"); + } + + @Test + public void testResolveQuery_equalsAndInNotAllowedTogether() + { + final Query query = queryExtractor.getWhereClause("(propName=testValue AND propName IN (testValue2, testValue3))"); + + //when + final Throwable actualException = catchThrowable(() -> QueryHelper.resolve(query).getProperty("propName")); + + assertThat(actualException).isInstanceOf(InvalidQueryException.class); + } + + @Test + public void testResolveQuery_equalsOrInAllowedTogether() + { + final Query query = queryExtractor.getWhereClause("(propName=testValue OR propName IN (testValue2, testValue3))"); + + //when + final WhereProperty whereProperty = QueryHelper.resolve(query).usingOrOperator().getProperty("propName"); + + assertThat(whereProperty).isNotNull(); + assertThat(whereProperty.getExpectedValuesForAllOf(WhereClauseParser.EQUALS, WhereClauseParser.IN).skipNegated()) + .isEqualTo(Map.of(WhereClauseParser.EQUALS, Set.of("testValue"), WhereClauseParser.IN, Set.of("testValue2", "testValue3"))); + } + + @Test + public void testResolveQuery_equalsAndInAllowedTogetherWithDifferentProperties() + { + final Query query = queryExtractor.getWhereClause("(propName=testValue AND propName2 IN (testValue2, testValue3))"); + + //when + final List properties = QueryHelper + .resolve(query) + .getProperties("propName", "propName2"); + + assertThat(properties.get(0).containsType(WhereClauseParser.EQUALS, false)).isTrue(); + assertThat(properties.get(0).containsType(WhereClauseParser.IN, false)).isFalse(); + assertThat(properties.get(0).getExpectedValuesForAnyOf(WhereClauseParser.EQUALS, WhereClauseParser.IN).skipNegated().get(WhereClauseParser.EQUALS)).containsOnly("testValue"); + assertThat(properties.get(0).getExpectedValuesForAnyOf(WhereClauseParser.EQUALS, WhereClauseParser.IN).skipNegated().containsKey(WhereClauseParser.IN)).isFalse(); + assertThat(properties.get(1).containsType(WhereClauseParser.EQUALS, false)).isFalse(); + assertThat(properties.get(1).containsType(WhereClauseParser.IN, false)).isTrue(); + assertThat(properties.get(1).getExpectedValuesForAnyOf(WhereClauseParser.EQUALS, WhereClauseParser.IN).skipNegated().containsKey(WhereClauseParser.EQUALS)).isFalse(); + assertThat(properties.get(1).getExpectedValuesForAnyOf(WhereClauseParser.EQUALS, WhereClauseParser.IN).skipNegated().get(WhereClauseParser.IN)).containsOnly("testValue2", "testValue3"); + } + + @Test + public void testResolveQuery_equalsAndInAllowedAlternately_equals() + { + final Query query = queryExtractor.getWhereClause("(propName=testValue)"); + + //when + final WhereProperty property = QueryHelper + .resolve(query) + .getProperty("propName"); + + assertThat(property.containsType(WhereClauseParser.EQUALS, false)).isTrue(); + assertThat(property.containsType(WhereClauseParser.IN, false)).isFalse(); + assertThat(property.getExpectedValuesForAnyOf(WhereClauseParser.EQUALS, WhereClauseParser.IN).skipNegated().get(WhereClauseParser.EQUALS)).containsOnly("testValue"); + assertThat(property.getExpectedValuesForAnyOf(WhereClauseParser.EQUALS, WhereClauseParser.IN).skipNegated().containsKey(WhereClauseParser.IN)).isFalse(); + } + + @Test + public void testResolveQuery_equalsAndInAllowedAlternately_in() + { + final Query query = queryExtractor.getWhereClause("(propName IN (testValue))"); + + //when + final WhereProperty property = QueryHelper + .resolve(query) + .getProperty("propName"); + + assertThat(property.containsType(WhereClauseParser.EQUALS, false)).isFalse(); + assertThat(property.containsType(WhereClauseParser.IN, false)).isTrue(); + assertThat(property.getExpectedValuesForAnyOf(WhereClauseParser.EQUALS, WhereClauseParser.IN).skipNegated().containsKey(WhereClauseParser.EQUALS)).isFalse(); + assertThat(property.getExpectedValuesForAnyOf(WhereClauseParser.EQUALS, WhereClauseParser.IN).skipNegated().get(WhereClauseParser.IN)).containsOnly("testValue"); + } + + @Test + public void testResolveQuery_missingEqualsClauseType() + { + final Query query = queryExtractor.getWhereClause("(propName MATCHES (testValue))"); + + //when + final WhereProperty property = QueryHelper + .resolve(query) + .getProperty("propName"); + + assertThatExceptionOfType(InvalidQueryException.class) + .isThrownBy(() -> property.getExpectedValuesForAllOf(WhereClauseParser.EQUALS, WhereClauseParser.MATCHES)); + } + + @Test + public void testResolveQuery_ignoreUnexpectedClauseType() + { + final Query query = queryExtractor.getWhereClause("(propName=testValue AND propName MATCHES (testValue))"); + + //when + final WhereProperty property = QueryHelper + .resolve(query) + .getProperty("propName"); + + assertThat(property.getExpectedValuesForAllOf(WhereClauseParser.EQUALS).skipNegated(WhereClauseParser.EQUALS)).containsOnly("testValue"); + } + + @Test + public void testResolveQuery_complexAndQuery() + { + final Query query = queryExtractor.getWhereClause("(a=v1 AND b>18 AND b<=65 AND NOT c BETWEEN ('2012-01-01','2012-12-31') AND d IN (v1, v2) AND e MATCHES ('*@mail.com') AND EXISTS (f/g))"); + + //when + final List properties = QueryHelper + .resolve(query) + .getProperties("a", "b", "c", "d", "e", "f/g"); + + assertThat(properties).hasSize(6); + assertThat(properties.get(0).getExpectedValuesFor(WhereProperty.ClauseType.EQUALS)).containsOnly("v1"); + assertThat(properties.get(1).containsAllTypes(WhereProperty.ClauseType.GREATER_THAN, WhereProperty.ClauseType.LESS_THAN_OR_EQUALS)).isTrue(); + assertThat(properties.get(1).getExpectedValuesFor(WhereProperty.ClauseType.GREATER_THAN)).containsOnly("18"); + assertThat(properties.get(1).getExpectedValuesFor(WhereProperty.ClauseType.LESS_THAN_OR_EQUALS)).containsOnly("65"); + assertThat(properties.get(2).getExpectedValuesFor(WhereProperty.ClauseType.NOT_BETWEEN)).containsOnly("2012-01-01", "2012-12-31"); + assertThat(properties.get(3).getExpectedValuesFor(WhereProperty.ClauseType.IN)).containsOnly("v1", "v2"); + assertThat(properties.get(4).getExpectedValuesFor(WhereProperty.ClauseType.MATCHES)).containsOnly("*@mail.com"); + assertThat(properties.get(5).containsType(WhereProperty.ClauseType.EXISTS)).isTrue(); + assertThat(properties.get(5).getExpectedValuesFor(WhereProperty.ClauseType.EXISTS)).isEmpty(); + } + + @Test + public void testResolveQuery_complexOrQuery() + { + final Query query = queryExtractor.getWhereClause("(a=v1 OR b>18 OR b<=65 OR NOT c BETWEEN ('2012-01-01','2012-12-31') OR d IN (v1, v2) OR e MATCHES ('*@mail.com') OR EXISTS (f/g))"); + + //when + final List properties = QueryHelper + .resolve(query) + .usingOrOperator() + .getProperties("a", "b", "c", "d", "e", "f/g"); + + assertThat(properties).hasSize(6); + assertThat(properties.get(0).getExpectedValuesFor(WhereProperty.ClauseType.EQUALS)).containsOnly("v1"); + assertThat(properties.get(1).containsAllTypes(WhereProperty.ClauseType.GREATER_THAN, WhereProperty.ClauseType.LESS_THAN_OR_EQUALS)).isTrue(); + assertThat(properties.get(1).getExpectedValuesFor(WhereProperty.ClauseType.GREATER_THAN)).containsOnly("18"); + assertThat(properties.get(1).getExpectedValuesFor(WhereProperty.ClauseType.LESS_THAN_OR_EQUALS)).containsOnly("65"); + assertThat(properties.get(2).getExpectedValuesFor(WhereProperty.ClauseType.NOT_BETWEEN)).containsOnly("2012-01-01", "2012-12-31"); + assertThat(properties.get(3).getExpectedValuesFor(WhereProperty.ClauseType.IN)).containsOnly("v1", "v2"); + assertThat(properties.get(4).getExpectedValuesFor(WhereProperty.ClauseType.MATCHES)).containsOnly("*@mail.com"); + assertThat(properties.get(5).containsType(WhereProperty.ClauseType.EXISTS)).isTrue(); + assertThat(properties.get(5).getExpectedValuesFor(WhereProperty.ClauseType.EXISTS)).isEmpty(); + } + + @Test + public void testResolveQuery_clauseTypeOptional() + { + final Query query = queryExtractor.getWhereClause("(propName MATCHES (testValue))"); + + //when + final WhereProperty property = QueryHelper + .resolve(query) + .getProperty("propName"); + + assertThat(property.getExpectedValuesForAnyOf(WhereClauseParser.EQUALS, WhereClauseParser.MATCHES).skipNegated(WhereClauseParser.MATCHES)).containsOnly("testValue"); + } + + @Test + public void testResolveQuery_optionalClauseTypesNotPresent() + { + final Query query = queryExtractor.getWhereClause("(propName=testValue AND propName MATCHES (testValue))"); + + //when + final WhereProperty property = QueryHelper + .resolve(query) + .getProperty("propName"); + + assertThatExceptionOfType(InvalidQueryException.class) + .isThrownBy(() -> property.getExpectedValuesForAnyOf(WhereClauseParser.IN)); + } + + @Test + public void testResolveQuery_matchesOrMatchesAllowed() + { + final Query query = queryExtractor.getWhereClause("(propName MATCHES ('test*') OR propName MATCHES ('*value*'))"); + + //when + final Collection expectedValues = QueryHelper + .resolve(query) + .usingOrOperator() + .getProperty("propName") + .getExpectedValuesFor(WhereClauseParser.MATCHES) + .skipNegated(); + + assertThat(expectedValues).containsOnly("test*", "*value*"); + } +} \ No newline at end of file diff --git a/repository/src/main/java/org/alfresco/repo/search/impl/AbstractCategoryServiceImpl.java b/repository/src/main/java/org/alfresco/repo/search/impl/AbstractCategoryServiceImpl.java index 228d3baee5..02887ff6aa 100644 --- a/repository/src/main/java/org/alfresco/repo/search/impl/AbstractCategoryServiceImpl.java +++ b/repository/src/main/java/org/alfresco/repo/search/impl/AbstractCategoryServiceImpl.java @@ -1,623 +1,648 @@ -/* - * #%L - * Alfresco Repository - * %% - * Copyright (C) 2005 - 2023 Alfresco Software Limited - * %% - * This file is part of the Alfresco software. - * If the software was purchased under a paid Alfresco license, the terms of - * the paid license agreement will prevail. Otherwise, the software is - * provided under the following open source license terms: - * - * Alfresco is free software: you can redistribute it and/or modify - * it under the terms of the GNU Lesser General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * Alfresco is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Lesser General Public License for more details. - * - * You should have received a copy of the GNU Lesser General Public License - * along with Alfresco. If not, see . - * #L% - */ -package org.alfresco.repo.search.impl; - -import java.util.Collection; -import java.util.Collections; -import java.util.HashMap; -import java.util.HashSet; -import java.util.Iterator; -import java.util.LinkedList; -import java.util.List; -import java.util.Map; -import java.util.Optional; -import java.util.Set; - -import org.alfresco.error.AlfrescoRuntimeException; -import org.alfresco.model.ContentModel; -import org.alfresco.query.CannedQueryPageDetails; -import org.alfresco.query.PagingRequest; -import org.alfresco.query.PagingResults; -import org.alfresco.repo.search.IndexerAndSearcher; -import org.alfresco.repo.search.IndexerException; -import org.alfresco.repo.tenant.TenantService; -import org.alfresco.service.Experimental; -import org.alfresco.service.cmr.dictionary.AspectDefinition; -import org.alfresco.service.cmr.dictionary.DataTypeDefinition; -import org.alfresco.service.cmr.dictionary.DictionaryService; -import org.alfresco.service.cmr.dictionary.PropertyDefinition; -import org.alfresco.service.cmr.repository.ChildAssociationRef; -import org.alfresco.service.cmr.repository.InvalidNodeRefException; -import org.alfresco.service.cmr.repository.NodeRef; -import org.alfresco.service.cmr.repository.NodeService; -import org.alfresco.service.cmr.repository.Path; -import org.alfresco.service.cmr.repository.StoreRef; -import org.alfresco.service.cmr.search.CategoryService; -import org.alfresco.service.cmr.search.CategoryServiceException; -import org.alfresco.service.cmr.search.LimitBy; -import org.alfresco.service.cmr.search.ResultSet; -import org.alfresco.service.cmr.search.ResultSetRow; -import org.alfresco.service.cmr.search.SearchParameters; -import org.alfresco.service.cmr.search.SearchService; -import org.alfresco.service.namespace.NamespacePrefixResolver; -import org.alfresco.service.namespace.QName; -import org.alfresco.util.ISO9075; -import org.alfresco.util.Pair; -import org.apache.commons.collections.CollectionUtils; - -/** - * Category service implementation - * - * @author andyh - */ -public abstract class AbstractCategoryServiceImpl implements CategoryService -{ - static final String CATEGORY_ROOT_NODE_NOT_FOUND = "Category root node not found"; - static final String NODE_WITH_CATEGORY_ROOT_TYPE_NOT_FOUND = "Node with category_root type not found"; - - protected NodeService nodeService; - - protected NodeService publicNodeService; - - protected TenantService tenantService; - - protected NamespacePrefixResolver namespacePrefixResolver; - - protected DictionaryService dictionaryService; - - protected IndexerAndSearcher indexerAndSearcher; - - protected int queryFetchSize = 5000; - - /** - * - */ - public AbstractCategoryServiceImpl() - { - super(); - } - - // Inversion of control support - - /** - * Set the node service - * - * @param nodeService NodeService - */ - public void setNodeService(NodeService nodeService) - { - this.nodeService = nodeService; - } - - /** - * Set the public node service - * - * @param publicNodeService NodeService - */ - public void setPublicNodeService(NodeService publicNodeService) - { - this.publicNodeService = publicNodeService; - } - - /** - * Set the tenant service - * - * @param tenantService TenantService - */ - public void setTenantService(TenantService tenantService) - { - this.tenantService = tenantService; - } - - /** - * Set the service to map prefixes to uris - * - * @param namespacePrefixResolver NamespacePrefixResolver - */ - public void setNamespacePrefixResolver(NamespacePrefixResolver namespacePrefixResolver) - { - this.namespacePrefixResolver = namespacePrefixResolver; - } - - /** - * Set the dictionary service - * - * @param dictionaryService DictionaryService - */ - public void setDictionaryService(DictionaryService dictionaryService) - { - this.dictionaryService = dictionaryService; - } - - /** - * Set the indexer and searcher - * - * @param indexerAndSearcher IndexerAndSearcher - */ - public void setIndexerAndSearcher(IndexerAndSearcher indexerAndSearcher) - { - this.indexerAndSearcher = indexerAndSearcher; - } - - public void setQueryFetchSize(int queryFetchSize) { - this.queryFetchSize = queryFetchSize; - } - - public Collection getChildren(NodeRef categoryRef, Mode mode, Depth depth) - { - return getChildren(categoryRef, mode, depth, false, null, queryFetchSize); - } - - public Collection getChildren(NodeRef categoryRef, Mode mode, Depth depth, String filter) - { - return getChildren(categoryRef, mode, depth, false, filter, queryFetchSize); - } - - private Collection getChildren(NodeRef categoryRef, Mode mode, Depth depth, boolean sortByName, String filter, int fetchSize) - { - if (categoryRef == null) - { - return Collections. emptyList(); - } - - categoryRef = tenantService.getBaseName(categoryRef); // for solr - - ResultSet resultSet = null; - try - { - StringBuilder luceneQuery = new StringBuilder(64); - - switch (mode) - { - case ALL: - luceneQuery.append("PATH:\""); - luceneQuery.append(buildXPath(nodeService.getPath(categoryRef))).append("/"); - if (depth.equals(Depth.ANY)) - { - luceneQuery.append("/"); - } - luceneQuery.append("*").append("\" "); - break; - case MEMBERS: - luceneQuery.append("PATH:\""); - luceneQuery.append(buildXPath(nodeService.getPath(categoryRef))).append("/"); - if (depth.equals(Depth.ANY)) - { - luceneQuery.append("/"); - } - luceneQuery.append("member").append("\" "); - break; - case SUB_CATEGORIES: - luceneQuery.append("+PATH:\""); - luceneQuery.append(buildXPath(nodeService.getPath(categoryRef))).append("/"); - if (depth.equals(Depth.ANY)) - { - luceneQuery.append("/"); - } - luceneQuery.append("*").append("\" "); - luceneQuery.append("+TYPE:\"" + ContentModel.TYPE_CATEGORY.toString() + "\""); - break; - } - if (filter != null) - { - luceneQuery.append(" " + "+@cm\\:name:\"*" + filter + "*\""); - } - - // Get a searcher that will include Categories added in this transaction - SearchService searcher = indexerAndSearcher.getSearcher(categoryRef.getStoreRef(), true); - - // Perform the search - SearchParameters searchParameters = new SearchParameters(); - resultSet = searcher.query(categoryRef.getStoreRef(), "lucene", luceneQuery.toString(), null); - searchParameters.setLanguage("lucene"); - if(sortByName) - { - searchParameters.addSort("@" + ContentModel.PROP_NAME, true); - } - searchParameters.setQuery(luceneQuery.toString()); - searchParameters.setLimit(-1); - searchParameters.setMaxItems(fetchSize); - searchParameters.setLimitBy(LimitBy.FINAL_SIZE); - searchParameters.addStore(categoryRef.getStoreRef()); - resultSet = searcher.query(searchParameters); - - // Convert from search results to the required Child Assocs - return resultSetToChildAssocCollection(resultSet); - } - finally - { - if (resultSet != null) - { - resultSet.close(); - } - } - } - - private String buildXPath(Path path) - { - StringBuilder pathBuffer = new StringBuilder(64); - for (Iterator elit = path.iterator(); elit.hasNext(); /**/) - { - Path.Element element = elit.next(); - if (!(element instanceof Path.ChildAssocElement)) - { - throw new IndexerException("Confused path: " + path); - } - Path.ChildAssocElement cae = (Path.ChildAssocElement) element; - if (cae.getRef().getParentRef() != null) - { - pathBuffer.append("/"); - pathBuffer.append(getPrefix(cae.getRef().getQName().getNamespaceURI())); - pathBuffer.append(ISO9075.encode(cae.getRef().getQName().getLocalName())); - } - } - return pathBuffer.toString(); - } - - HashMap prefixLookup = new HashMap(); - - protected String getPrefix(String uri) - { - String prefix = prefixLookup.get(uri); - if (prefix == null) - { - Collection prefixes = namespacePrefixResolver.getPrefixes(uri); - for (String first : prefixes) - { - prefix = first; - break; - } - - prefixLookup.put(uri, prefix); - } - if (prefix == null) - { - return ""; - } - else - { - return prefix + ":"; - } - - } - - private Collection resultSetToChildAssocCollection(ResultSet resultSet) - { - List collection = new LinkedList(); - if (resultSet != null) - { - for (ResultSetRow row : resultSet) - { - try - { - ChildAssociationRef car = nodeService.getPrimaryParent(row.getNodeRef()); - collection.add(car); - } - catch(InvalidNodeRefException inre) - { - // keep going the node has gone beneath us just skip it - } - } - } - return collection; - // The caller closes the result set - } - - public Collection getCategories(StoreRef storeRef, QName aspectQName, Depth depth) - { - Collection assocs = new LinkedList(); - Set nodeRefs = getClassificationNodes(storeRef, aspectQName); - for (NodeRef nodeRef : nodeRefs) - { - assocs.addAll(getChildren(nodeRef, Mode.SUB_CATEGORIES, depth)); - } - return assocs; - } - - protected Set getClassificationNodes(StoreRef storeRef, QName qname) - { - ResultSet resultSet = null; - try - { - resultSet = indexerAndSearcher.getSearcher(storeRef, false).query(storeRef, "lucene", - "PATH:\"/" + getPrefix(qname.getNamespaceURI()) + ISO9075.encode(qname.getLocalName()) + "\"", null); - - Set nodeRefs = new HashSet(resultSet.length()); - for (ResultSetRow row : resultSet) - { - nodeRefs.add(row.getNodeRef()); - } - - return nodeRefs; - } - finally - { - if (resultSet != null) - { - resultSet.close(); - } - } - } - - public Collection getClassifications(StoreRef storeRef) - { - ResultSet resultSet = null; - try - { - resultSet = indexerAndSearcher.getSearcher(storeRef, false).query(storeRef, "lucene", "PATH:\"//cm:categoryRoot/*\"", null); - return resultSetToChildAssocCollection(resultSet); - } - finally - { - if (resultSet != null) - { - resultSet.close(); - } - } - } - - public Collection getClassificationAspects() - { - return dictionaryService.getSubAspects(ContentModel.ASPECT_CLASSIFIABLE, true); - } - - public NodeRef createClassification(StoreRef storeRef, QName typeName, String attributeName) - { - throw new UnsupportedOperationException(); - } - - public PagingResults getRootCategories(StoreRef storeRef, QName aspectName, PagingRequest pagingRequest, boolean sortByName) - { - return getRootCategories(storeRef, aspectName, pagingRequest, sortByName, null); - } - - public PagingResults getRootCategories(StoreRef storeRef, QName aspectName, PagingRequest pagingRequest, boolean sortByName, String filter) - { - final List assocs = new LinkedList(); - Set nodeRefs = getClassificationNodes(storeRef, aspectName); - - final int skipCount = pagingRequest.getSkipCount(); - final int maxItems = pagingRequest.getMaxItems(); - final int size = (maxItems == CannedQueryPageDetails.DEFAULT_PAGE_SIZE ? CannedQueryPageDetails.DEFAULT_PAGE_SIZE : skipCount + maxItems); - int count = 0; - boolean moreItems = false; - - OUTER: for(NodeRef nodeRef : nodeRefs) - { - Collection children = getChildren(nodeRef, Mode.SUB_CATEGORIES, Depth.IMMEDIATE, sortByName, filter, skipCount + maxItems + 1); - for(ChildAssociationRef child : children) - { - count++; - - if(count <= skipCount) - { - continue; - } - - if(count > size) - { - moreItems = true; - break OUTER; - } - - assocs.add(child); - } - } - - final boolean hasMoreItems = moreItems; - return new PagingResults() - { - @Override - public List getPage() - { - return assocs; - } - - @Override - public boolean hasMoreItems() - { - return hasMoreItems; - } - - @Override - public Pair getTotalResultCount() - { - return new Pair(null, null); - } - - @Override - public String getQueryExecutionId() - { - return null; - } - }; - } - - public Collection getRootCategories(StoreRef storeRef, QName aspectName) - { - return getRootCategories(storeRef, aspectName, null); - } - - public Collection getRootCategories(StoreRef storeRef, QName aspectName, String filter) - { - Collection assocs = new LinkedList(); - Set nodeRefs = getClassificationNodes(storeRef, aspectName); - for (NodeRef nodeRef : nodeRefs) - { - assocs.addAll(getChildren(nodeRef, Mode.SUB_CATEGORIES, Depth.IMMEDIATE, false, filter, queryFetchSize)); - } - return assocs; - } - - public ChildAssociationRef getCategory(NodeRef parent, QName aspectName, String name) - { - String uri = nodeService.getPrimaryParent(parent).getQName().getNamespaceURI(); - String validLocalName = QName.createValidLocalName(name); - Collection assocs = nodeService.getChildAssocs(parent, ContentModel.ASSOC_SUBCATEGORIES, - QName.createQName(uri, validLocalName), false); - if (assocs.isEmpty()) - { - return null; - } - return assocs.iterator().next(); - } - - public Collection getRootCategories(StoreRef storeRef, QName aspectName, String name, - boolean create) - { - Set nodeRefs = getClassificationNodes(storeRef, aspectName); - if (nodeRefs.isEmpty()) - { - return Collections.emptySet(); - } - Collection assocs = new LinkedList(); - for (NodeRef nodeRef : nodeRefs) - { - ChildAssociationRef category = getCategory(nodeRef, aspectName, name); - if (category != null) - { - assocs.add(category); - } - } - if (create && assocs.isEmpty()) - { - assocs.add(createCategoryInternal(nodeRefs.iterator().next(), name)); - } - return assocs; - } - - public NodeRef createCategory(NodeRef parent, String name) - { - return createCategoryInternal(parent, name).getChildRef(); - } - - private ChildAssociationRef createCategoryInternal(NodeRef parent, String name) - { - if (!nodeService.exists(parent)) - { - throw new AlfrescoRuntimeException("Missing category?"); - } - String uri = nodeService.getPrimaryParent(parent).getQName().getNamespaceURI(); - String validLocalName = QName.createValidLocalName(name); - ChildAssociationRef newCategory = publicNodeService.createNode(parent, ContentModel.ASSOC_SUBCATEGORIES, QName.createQName(uri, validLocalName), ContentModel.TYPE_CATEGORY); - publicNodeService.setProperty(newCategory.getChildRef(), ContentModel.PROP_NAME, name); - return newCategory; - } - - public NodeRef createRootCategory(StoreRef storeRef, QName aspectName, String name) - { - Set nodeRefs = getClassificationNodes(storeRef, aspectName); - if (nodeRefs.size() == 0) - { - throw new AlfrescoRuntimeException("Missing classification: " + aspectName); - } - NodeRef parent = nodeRefs.iterator().next(); - return createCategory(parent, name); - } - - public void deleteCategory(NodeRef nodeRef) - { - publicNodeService.deleteNode(nodeRef); - } - - public void deleteClassification(StoreRef storeRef, QName aspectName) - { - throw new UnsupportedOperationException(); - } - - public abstract List> getTopCategories(StoreRef storeRef, QName aspectName, int count); - - /** - * Creates search query parameters used to get top categories. - * Can be used as a base both wih SOLR and ES. - * @param storeRef Node store reference - * @param aspectName Aspect name. "cm:generalclassifiable" aspect should be used for usual cases. - * It is possible to use a custom aspect but it must have valid category property - * @param count Will be used as faceted results limit, when system has very many categories this must be reflecting that number - * @return SearchParameters to perform search for top categories. - */ - protected SearchParameters createSearchTopCategoriesParameters(StoreRef storeRef, QName aspectName, int count) { - final AspectDefinition aspectDefinition = dictionaryService.getAspect(aspectName); - if(aspectDefinition == null) - { - throw new IllegalStateException("Unknown aspect"); - } - final Map aspectProperties = aspectDefinition.getProperties(); - final Optional catProperty = aspectProperties.entrySet().stream() - //for backwards compatibility I'm leaving the part where we get custom category aspects - .filter(ap -> ContentModel.ASPECT_GEN_CLASSIFIABLE.isMatch(aspectName) || isValidCategoryTypeProperty(aspectName, ap)) - .map(Map.Entry::getKey) - .findFirst(); - - return catProperty.map(cp -> { - final String field = "@" + cp; - final SearchParameters sp = new SearchParameters(); - sp.addStore(storeRef); - sp.setQuery(cp + ":*"); - //we only care about faceted results and don't need query results so we can limit them to minimum - sp.setMaxItems(1); - sp.setSkipCount(0); - final SearchParameters.FieldFacet ff = new SearchParameters.FieldFacet(field); - ff.setLimitOrNull(count < 0 ? null : count); - sp.addFieldFacet(ff); - return sp; - }) - .orElseThrow(() -> new IllegalStateException("Aspect does not have category property mirroring the aspect name")); - } - - /** - * Checks whether given aspect property definition is valid category property - - * @param aspectName Aspect name - * @param propertyDef Aspect property definition. - * @return is valid category property - */ - private boolean isValidCategoryTypeProperty(QName aspectName, Map.Entry propertyDef) - { - return propertyDef.getKey().getNamespaceURI().equals(aspectName.getNamespaceURI()) && - propertyDef.getKey().getLocalName().equals(aspectName.getLocalName()) && - DataTypeDefinition.CATEGORY.equals(propertyDef.getValue().getDataType().getName()); - } - - @Override - @Experimental - public Optional getRootCategoryNodeRef(final StoreRef storeRef) - { - final NodeRef rootNode = nodeService.getRootNode(storeRef); - final ChildAssociationRef categoryRoot = nodeService.getChildAssocs(rootNode, Set.of(ContentModel.TYPE_CATEGORYROOT)).stream() - .findFirst() - .orElseThrow(() -> new CategoryServiceException(NODE_WITH_CATEGORY_ROOT_TYPE_NOT_FOUND)); - final List categoryRootAssocs = nodeService.getChildAssocs(categoryRoot.getChildRef()); - if (CollectionUtils.isEmpty(categoryRootAssocs)) - { - throw new CategoryServiceException(CATEGORY_ROOT_NODE_NOT_FOUND); - } - return categoryRootAssocs.stream() - .filter(ca -> ca.getQName().equals(ContentModel.ASPECT_GEN_CLASSIFIABLE)) - .map(ChildAssociationRef::getChildRef) - .findFirst(); - } -} +/* + * #%L + * Alfresco Repository + * %% + * Copyright (C) 2005 - 2023 Alfresco Software Limited + * %% + * This file is part of the Alfresco software. + * If the software was purchased under a paid Alfresco license, the terms of + * the paid license agreement will prevail. Otherwise, the software is + * provided under the following open source license terms: + * + * Alfresco is free software: you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Alfresco is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with Alfresco. If not, see . + * #L% + */ +package org.alfresco.repo.search.impl; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.Comparator; +import java.util.HashMap; +import java.util.HashSet; +import java.util.Iterator; +import java.util.LinkedList; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.StringJoiner; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +import org.alfresco.error.AlfrescoRuntimeException; +import org.alfresco.model.ContentModel; +import org.alfresco.query.CannedQueryPageDetails; +import org.alfresco.query.ListBackedPagingResults; +import org.alfresco.query.PagingRequest; +import org.alfresco.query.PagingResults; +import org.alfresco.repo.search.IndexerAndSearcher; +import org.alfresco.repo.search.IndexerException; +import org.alfresco.repo.tenant.TenantService; +import org.alfresco.service.Experimental; +import org.alfresco.service.cmr.dictionary.AspectDefinition; +import org.alfresco.service.cmr.dictionary.DataTypeDefinition; +import org.alfresco.service.cmr.dictionary.DictionaryService; +import org.alfresco.service.cmr.dictionary.PropertyDefinition; +import org.alfresco.service.cmr.repository.ChildAssociationRef; +import org.alfresco.service.cmr.repository.InvalidNodeRefException; +import org.alfresco.service.cmr.repository.NodeRef; +import org.alfresco.service.cmr.repository.NodeService; +import org.alfresco.service.cmr.repository.Path; +import org.alfresco.service.cmr.repository.StoreRef; +import org.alfresco.service.cmr.search.CategoryService; +import org.alfresco.service.cmr.search.CategoryServiceException; +import org.alfresco.service.cmr.search.LimitBy; +import org.alfresco.service.cmr.search.ResultSet; +import org.alfresco.service.cmr.search.ResultSetRow; +import org.alfresco.service.cmr.search.SearchParameters; +import org.alfresco.service.cmr.search.SearchService; +import org.alfresco.service.namespace.NamespacePrefixResolver; +import org.alfresco.service.namespace.QName; +import org.alfresco.service.namespace.RegexQNamePattern; +import org.alfresco.util.ISO9075; +import org.alfresco.util.Pair; +import org.alfresco.util.collections.Function; +import org.apache.commons.collections.CollectionUtils; + +/** + * Category service implementation + * + * @author andyh + */ +public abstract class AbstractCategoryServiceImpl implements CategoryService +{ + static final String CATEGORY_ROOT_NODE_NOT_FOUND = "Category root node not found"; + static final String NODE_WITH_CATEGORY_ROOT_TYPE_NOT_FOUND = "Node with category_root type not found"; + + protected NodeService nodeService; + + protected NodeService publicNodeService; + + protected TenantService tenantService; + + protected NamespacePrefixResolver namespacePrefixResolver; + + protected DictionaryService dictionaryService; + + protected IndexerAndSearcher indexerAndSearcher; + + protected int queryFetchSize = 5000; + + /** + * + */ + public AbstractCategoryServiceImpl() + { + super(); + } + + // Inversion of control support + + /** + * Set the node service + * + * @param nodeService NodeService + */ + public void setNodeService(NodeService nodeService) + { + this.nodeService = nodeService; + } + + /** + * Set the public node service + * + * @param publicNodeService NodeService + */ + public void setPublicNodeService(NodeService publicNodeService) + { + this.publicNodeService = publicNodeService; + } + + /** + * Set the tenant service + * + * @param tenantService TenantService + */ + public void setTenantService(TenantService tenantService) + { + this.tenantService = tenantService; + } + + /** + * Set the service to map prefixes to uris + * + * @param namespacePrefixResolver NamespacePrefixResolver + */ + public void setNamespacePrefixResolver(NamespacePrefixResolver namespacePrefixResolver) + { + this.namespacePrefixResolver = namespacePrefixResolver; + } + + /** + * Set the dictionary service + * + * @param dictionaryService DictionaryService + */ + public void setDictionaryService(DictionaryService dictionaryService) + { + this.dictionaryService = dictionaryService; + } + + /** + * Set the indexer and searcher + * + * @param indexerAndSearcher IndexerAndSearcher + */ + public void setIndexerAndSearcher(IndexerAndSearcher indexerAndSearcher) + { + this.indexerAndSearcher = indexerAndSearcher; + } + + public void setQueryFetchSize(int queryFetchSize) { + this.queryFetchSize = queryFetchSize; + } + + public Collection getChildren(NodeRef categoryRef, Mode mode, Depth depth) + { + return getChildren(categoryRef, mode, depth, false, (Collection) null, queryFetchSize); + } + + public Collection getChildren(NodeRef categoryRef, Mode mode, Depth depth, String filter) + { + return getChildren(categoryRef, mode, depth, false, filter, queryFetchSize); + } + + private Collection getChildren(NodeRef categoryRef, Mode mode, Depth depth, boolean sortByName, String filter, int fetchSize) + { + Collection matchingFilter = Optional.ofNullable(filter).map(f -> "*".concat(f).concat("*")).map(Set::of).orElse(null); + return getChildren(categoryRef, mode, depth, sortByName, matchingFilter, fetchSize); + } + + private Collection getChildren(NodeRef categoryRef, Mode mode, Depth depth, boolean sortByName, Collection namesFilter, int fetchSize) + { + if (categoryRef == null) + { + return Collections. emptyList(); + } + + categoryRef = tenantService.getBaseName(categoryRef); // for solr + + ResultSet resultSet = null; + try + { + StringBuilder luceneQuery = new StringBuilder(64); + + switch (mode) + { + case ALL: + luceneQuery.append("PATH:\""); + luceneQuery.append(buildXPath(nodeService.getPath(categoryRef))).append("/"); + if (depth.equals(Depth.ANY)) + { + luceneQuery.append("/"); + } + luceneQuery.append("*").append("\" "); + break; + case MEMBERS: + luceneQuery.append("PATH:\""); + luceneQuery.append(buildXPath(nodeService.getPath(categoryRef))).append("/"); + if (depth.equals(Depth.ANY)) + { + luceneQuery.append("/"); + } + luceneQuery.append("member").append("\" "); + break; + case SUB_CATEGORIES: + luceneQuery.append("+PATH:\""); + luceneQuery.append(buildXPath(nodeService.getPath(categoryRef))).append("/"); + if (depth.equals(Depth.ANY)) + { + luceneQuery.append("/"); + } + luceneQuery.append("*").append("\" "); + luceneQuery.append("+TYPE:\"" + ContentModel.TYPE_CATEGORY + "\""); + break; + } + if (CollectionUtils.isNotEmpty(namesFilter)) + { + final StringJoiner filterJoiner = new StringJoiner(" OR ", " +(", ")"); + namesFilter.forEach(nameFilter -> filterJoiner.add("@cm\\:name:\"" + nameFilter + "\"")); + luceneQuery.append(filterJoiner); + } + + // Get a searcher that will include Categories added in this transaction + SearchService searcher = indexerAndSearcher.getSearcher(categoryRef.getStoreRef(), true); + + // Perform the search + SearchParameters searchParameters = new SearchParameters(); + resultSet = searcher.query(categoryRef.getStoreRef(), "lucene", luceneQuery.toString(), null); + searchParameters.setLanguage("lucene"); + if(sortByName) + { + searchParameters.addSort("@" + ContentModel.PROP_NAME, true); + } + searchParameters.setQuery(luceneQuery.toString()); + searchParameters.setLimit(-1); + searchParameters.setMaxItems(fetchSize); + searchParameters.setLimitBy(LimitBy.FINAL_SIZE); + searchParameters.addStore(categoryRef.getStoreRef()); + resultSet = searcher.query(searchParameters); + + // Convert from search results to the required Child Assocs + return resultSetToChildAssocCollection(resultSet); + } + finally + { + if (resultSet != null) + { + resultSet.close(); + } + } + } + + private String buildXPath(Path path) + { + StringBuilder pathBuffer = new StringBuilder(64); + for (Iterator elit = path.iterator(); elit.hasNext(); /**/) + { + Path.Element element = elit.next(); + if (!(element instanceof Path.ChildAssocElement)) + { + throw new IndexerException("Confused path: " + path); + } + Path.ChildAssocElement cae = (Path.ChildAssocElement) element; + if (cae.getRef().getParentRef() != null) + { + pathBuffer.append("/"); + pathBuffer.append(getPrefix(cae.getRef().getQName().getNamespaceURI())); + pathBuffer.append(ISO9075.encode(cae.getRef().getQName().getLocalName())); + } + } + return pathBuffer.toString(); + } + + HashMap prefixLookup = new HashMap(); + + protected String getPrefix(String uri) + { + String prefix = prefixLookup.get(uri); + if (prefix == null) + { + Collection prefixes = namespacePrefixResolver.getPrefixes(uri); + for (String first : prefixes) + { + prefix = first; + break; + } + + prefixLookup.put(uri, prefix); + } + if (prefix == null) + { + return ""; + } + else + { + return prefix + ":"; + } + + } + + private Collection resultSetToChildAssocCollection(ResultSet resultSet) + { + List collection = new LinkedList(); + if (resultSet != null) + { + for (ResultSetRow row : resultSet) + { + try + { + ChildAssociationRef car = nodeService.getPrimaryParent(row.getNodeRef()); + collection.add(car); + } + catch(InvalidNodeRefException inre) + { + // keep going the node has gone beneath us just skip it + } + } + } + return collection; + // The caller closes the result set + } + + public Collection getCategories(StoreRef storeRef, QName aspectQName, Depth depth) + { + Collection assocs = new LinkedList(); + Set nodeRefs = getClassificationNodes(storeRef, aspectQName); + for (NodeRef nodeRef : nodeRefs) + { + assocs.addAll(getChildren(nodeRef, Mode.SUB_CATEGORIES, depth)); + } + return assocs; + } + + protected Set getClassificationNodes(StoreRef storeRef, QName qname) + { + ResultSet resultSet = null; + try + { + resultSet = indexerAndSearcher.getSearcher(storeRef, false).query(storeRef, "lucene", + "PATH:\"/" + getPrefix(qname.getNamespaceURI()) + ISO9075.encode(qname.getLocalName()) + "\"", null); + + Set nodeRefs = new HashSet(resultSet.length()); + for (ResultSetRow row : resultSet) + { + nodeRefs.add(row.getNodeRef()); + } + + return nodeRefs; + } + finally + { + if (resultSet != null) + { + resultSet.close(); + } + } + } + + public Collection getClassifications(StoreRef storeRef) + { + ResultSet resultSet = null; + try + { + resultSet = indexerAndSearcher.getSearcher(storeRef, false).query(storeRef, "lucene", "PATH:\"//cm:categoryRoot/*\"", null); + return resultSetToChildAssocCollection(resultSet); + } + finally + { + if (resultSet != null) + { + resultSet.close(); + } + } + } + + public Collection getClassificationAspects() + { + return dictionaryService.getSubAspects(ContentModel.ASPECT_CLASSIFIABLE, true); + } + + public NodeRef createClassification(StoreRef storeRef, QName typeName, String attributeName) + { + throw new UnsupportedOperationException(); + } + + public PagingResults getRootCategories(StoreRef storeRef, QName aspectName, PagingRequest pagingRequest, boolean sortByName) + { + return getRootCategories(storeRef, aspectName, pagingRequest, sortByName, null, null); + } + + public PagingResults getRootCategories(StoreRef storeRef, QName aspectName, PagingRequest pagingRequest, boolean sortByName, String filter) + { + final Collection alikeNamesFilter = Optional.ofNullable(filter).map(f -> "*".concat(f).concat("*")).map(Set::of).orElse(null); + return getRootCategories(storeRef, aspectName, pagingRequest, sortByName, null, alikeNamesFilter); + } + + public PagingResults getRootCategories(StoreRef storeRef, QName aspectName, PagingRequest pagingRequest, boolean sortByName, + Collection exactNamesFilter, Collection alikeNamesFilter) + { + final Set nodeRefs = getClassificationNodes(storeRef, aspectName); + final List associations = new LinkedList<>(); + final int skipCount = pagingRequest.getSkipCount(); + final int maxItems = pagingRequest.getMaxItems(); + final int size = (maxItems == CannedQueryPageDetails.DEFAULT_PAGE_SIZE ? CannedQueryPageDetails.DEFAULT_PAGE_SIZE : skipCount + maxItems); + int count = 0; + boolean moreItems = false; + + final Function> childNodesSupplier = (nodeRef) -> { + final Set childNodes = new HashSet<>(); + if (CollectionUtils.isEmpty(exactNamesFilter) && CollectionUtils.isEmpty(alikeNamesFilter)) + { + // lookup in DB without filtering + childNodes.addAll(nodeService.getChildAssocs(nodeRef, ContentModel.ASSOC_SUBCATEGORIES, RegexQNamePattern.MATCH_ALL)); + } + else + { + if (CollectionUtils.isNotEmpty(exactNamesFilter)) + { + // lookup in DB filtering by name + childNodes.addAll(nodeService.getChildrenByName(nodeRef, ContentModel.ASSOC_SUBCATEGORIES, exactNamesFilter)); + } + if (CollectionUtils.isNotEmpty(alikeNamesFilter)) + { + // lookup using search engin filtering by name + childNodes.addAll(getChildren(nodeRef, Mode.SUB_CATEGORIES, Depth.IMMEDIATE, sortByName, alikeNamesFilter, skipCount + maxItems + 1)); + } + } + + Stream childNodesStream = childNodes.stream(); + if (sortByName) + { + childNodesStream = childNodesStream.sorted(Comparator.comparing(tag -> tag.getQName().getLocalName())); + } + return childNodesStream.collect(Collectors.toList()); + }; + + OUTER_LOOP: for(NodeRef nodeRef : nodeRefs) + { + Collection children = childNodesSupplier.apply(nodeRef); + for(ChildAssociationRef child : children) + { + count++; + + if(count <= skipCount) + { + continue; + } + + if(count > size) + { + moreItems = true; + break OUTER_LOOP; + } + + associations.add(child); + } + } + + return new ListBackedPagingResults<>(associations, moreItems); + } + + public Collection getRootCategories(StoreRef storeRef, QName aspectName) + { + return getRootCategories(storeRef, aspectName, null); + } + + public Collection getRootCategories(StoreRef storeRef, QName aspectName, String filter) + { + Collection assocs = new LinkedList(); + Set nodeRefs = getClassificationNodes(storeRef, aspectName); + for (NodeRef nodeRef : nodeRefs) + { + assocs.addAll(getChildren(nodeRef, Mode.SUB_CATEGORIES, Depth.IMMEDIATE, false, filter, queryFetchSize)); + } + return assocs; + } + + public ChildAssociationRef getCategory(NodeRef parent, QName aspectName, String name) + { + String uri = nodeService.getPrimaryParent(parent).getQName().getNamespaceURI(); + String validLocalName = QName.createValidLocalName(name); + Collection assocs = nodeService.getChildAssocs(parent, ContentModel.ASSOC_SUBCATEGORIES, + QName.createQName(uri, validLocalName), false); + if (assocs.isEmpty()) + { + return null; + } + return assocs.iterator().next(); + } + + public Collection getRootCategories(StoreRef storeRef, QName aspectName, String name, + boolean create) + { + Set nodeRefs = getClassificationNodes(storeRef, aspectName); + if (nodeRefs.isEmpty()) + { + return Collections.emptySet(); + } + Collection assocs = new LinkedList(); + for (NodeRef nodeRef : nodeRefs) + { + ChildAssociationRef category = getCategory(nodeRef, aspectName, name); + if (category != null) + { + assocs.add(category); + } + } + if (create && assocs.isEmpty()) + { + assocs.add(createCategoryInternal(nodeRefs.iterator().next(), name)); + } + return assocs; + } + + public NodeRef createCategory(NodeRef parent, String name) + { + return createCategoryInternal(parent, name).getChildRef(); + } + + private ChildAssociationRef createCategoryInternal(NodeRef parent, String name) + { + if (!nodeService.exists(parent)) + { + throw new AlfrescoRuntimeException("Missing category?"); + } + String uri = nodeService.getPrimaryParent(parent).getQName().getNamespaceURI(); + String validLocalName = QName.createValidLocalName(name); + ChildAssociationRef newCategory = publicNodeService.createNode(parent, ContentModel.ASSOC_SUBCATEGORIES, QName.createQName(uri, validLocalName), ContentModel.TYPE_CATEGORY); + publicNodeService.setProperty(newCategory.getChildRef(), ContentModel.PROP_NAME, name); + return newCategory; + } + + public NodeRef createRootCategory(StoreRef storeRef, QName aspectName, String name) + { + Set nodeRefs = getClassificationNodes(storeRef, aspectName); + if (nodeRefs.size() == 0) + { + throw new AlfrescoRuntimeException("Missing classification: " + aspectName); + } + NodeRef parent = nodeRefs.iterator().next(); + return createCategory(parent, name); + } + + public void deleteCategory(NodeRef nodeRef) + { + publicNodeService.deleteNode(nodeRef); + } + + public void deleteClassification(StoreRef storeRef, QName aspectName) + { + throw new UnsupportedOperationException(); + } + + public abstract List> getTopCategories(StoreRef storeRef, QName aspectName, int count); + + /** + * Creates search query parameters used to get top categories. + * Can be used as a base both wih SOLR and ES. + * @param storeRef Node store reference + * @param aspectName Aspect name. "cm:generalclassifiable" aspect should be used for usual cases. + * It is possible to use a custom aspect but it must have valid category property + * @param count Will be used as faceted results limit, when system has very many categories this must be reflecting that number + * @return SearchParameters to perform search for top categories. + */ + protected SearchParameters createSearchTopCategoriesParameters(StoreRef storeRef, QName aspectName, int count) { + final AspectDefinition aspectDefinition = dictionaryService.getAspect(aspectName); + if(aspectDefinition == null) + { + throw new IllegalStateException("Unknown aspect"); + } + final Map aspectProperties = aspectDefinition.getProperties(); + final Optional catProperty = aspectProperties.entrySet().stream() + //for backwards compatibility I'm leaving the part where we get custom category aspects + .filter(ap -> ContentModel.ASPECT_GEN_CLASSIFIABLE.isMatch(aspectName) || isValidCategoryTypeProperty(aspectName, ap)) + .map(Map.Entry::getKey) + .findFirst(); + + return catProperty.map(cp -> { + final String field = "@" + cp; + final SearchParameters sp = new SearchParameters(); + sp.addStore(storeRef); + sp.setQuery(cp + ":*"); + //we only care about faceted results and don't need query results so we can limit them to minimum + sp.setMaxItems(1); + sp.setSkipCount(0); + final SearchParameters.FieldFacet ff = new SearchParameters.FieldFacet(field); + ff.setLimitOrNull(count < 0 ? null : count); + sp.addFieldFacet(ff); + return sp; + }) + .orElseThrow(() -> new IllegalStateException("Aspect does not have category property mirroring the aspect name")); + } + + /** + * Checks whether given aspect property definition is valid category property + + * @param aspectName Aspect name + * @param propertyDef Aspect property definition. + * @return is valid category property + */ + private boolean isValidCategoryTypeProperty(QName aspectName, Map.Entry propertyDef) + { + return propertyDef.getKey().getNamespaceURI().equals(aspectName.getNamespaceURI()) && + propertyDef.getKey().getLocalName().equals(aspectName.getLocalName()) && + DataTypeDefinition.CATEGORY.equals(propertyDef.getValue().getDataType().getName()); + } + + @Override + @Experimental + public Optional getRootCategoryNodeRef(final StoreRef storeRef) + { + final NodeRef rootNode = nodeService.getRootNode(storeRef); + final ChildAssociationRef categoryRoot = nodeService.getChildAssocs(rootNode, Set.of(ContentModel.TYPE_CATEGORYROOT)).stream() + .findFirst() + .orElseThrow(() -> new CategoryServiceException(NODE_WITH_CATEGORY_ROOT_TYPE_NOT_FOUND)); + final List categoryRootAssocs = nodeService.getChildAssocs(categoryRoot.getChildRef()); + if (CollectionUtils.isEmpty(categoryRootAssocs)) + { + throw new CategoryServiceException(CATEGORY_ROOT_NODE_NOT_FOUND); + } + return categoryRootAssocs.stream() + .filter(ca -> ca.getQName().equals(ContentModel.ASPECT_GEN_CLASSIFIABLE)) + .map(ChildAssociationRef::getChildRef) + .findFirst(); + } +} diff --git a/repository/src/main/java/org/alfresco/repo/tagging/TaggingServiceImpl.java b/repository/src/main/java/org/alfresco/repo/tagging/TaggingServiceImpl.java index b93b99fb42..29fac1fe33 100644 --- a/repository/src/main/java/org/alfresco/repo/tagging/TaggingServiceImpl.java +++ b/repository/src/main/java/org/alfresco/repo/tagging/TaggingServiceImpl.java @@ -42,6 +42,7 @@ import java.util.List; import java.util.Map; import java.util.Objects; import java.util.Set; +import java.util.function.Function; import java.util.stream.Collectors; import org.alfresco.model.ContentModel; @@ -914,51 +915,23 @@ public class TaggingServiceImpl implements TaggingService, return new EmptyPagingResults>(); } + public PagingResults> getTags(StoreRef storeRef, PagingRequest pagingRequest) + { + return getTags(storeRef, pagingRequest, null, null); + } + /** * @see org.alfresco.service.cmr.tagging.TaggingService#getTags(org.alfresco.service.cmr.repository.StoreRef, org.alfresco.query.PagingRequest) */ - public PagingResults> getTags(StoreRef storeRef, PagingRequest pagingRequest) + public PagingResults> getTags(StoreRef storeRef, PagingRequest pagingRequest, Collection exactNamesFilter, Collection alikeNamesFilter) { ParameterCheck.mandatory("storeRef", storeRef); - PagingResults rootCategories = this.categoryService.getRootCategories(storeRef, ContentModel.ASPECT_TAGGABLE, pagingRequest, true); - final List> result = new ArrayList>(rootCategories.getPage().size()); - for (ChildAssociationRef rootCategory : rootCategories.getPage()) - { - String name = (String)this.nodeService.getProperty(rootCategory.getChildRef(), ContentModel.PROP_NAME); - result.add(new Pair(rootCategory.getChildRef(), name)); - } - final boolean hasMoreItems = rootCategories.hasMoreItems(); - final Pair totalResultCount = rootCategories.getTotalResultCount(); - final String queryExecutionId = rootCategories.getQueryExecutionId(); - rootCategories = null; + PagingResults rootCategories = categoryService.getRootCategories(storeRef, ContentModel.ASPECT_TAGGABLE, pagingRequest, true, + exactNamesFilter, alikeNamesFilter); - return new PagingResults>() - { - @Override - public List> getPage() - { - return result; - } - - @Override - public boolean hasMoreItems() - { - return hasMoreItems; - } - - @Override - public Pair getTotalResultCount() - { - return totalResultCount; - } - - @Override - public String getQueryExecutionId() - { - return queryExecutionId; - } - }; + return mapPagingResult(rootCategories, + (childAssociation) -> new Pair<>(childAssociation.getChildRef(), childAssociation.getQName().getLocalName())); } /** @@ -1600,4 +1573,36 @@ public class TaggingServiceImpl implements TaggingService, createTagBehaviour.enable(); } } + + private PagingResults mapPagingResult(final PagingResults pagingResults, final Function mapper) + { + return new PagingResults() + { + @Override + public List getPage() + { + return pagingResults.getPage().stream() + .map(mapper) + .collect(Collectors.toList()); + } + + @Override + public boolean hasMoreItems() + { + return pagingResults.hasMoreItems(); + } + + @Override + public Pair getTotalResultCount() + { + return pagingResults.getTotalResultCount(); + } + + @Override + public String getQueryExecutionId() + { + return pagingResults.getQueryExecutionId(); + } + }; + } } diff --git a/repository/src/main/java/org/alfresco/service/cmr/search/CategoryService.java b/repository/src/main/java/org/alfresco/service/cmr/search/CategoryService.java index 91c9853aea..355d53cbf9 100644 --- a/repository/src/main/java/org/alfresco/service/cmr/search/CategoryService.java +++ b/repository/src/main/java/org/alfresco/service/cmr/search/CategoryService.java @@ -30,6 +30,7 @@ import java.util.List; import java.util.Optional; import org.alfresco.api.AlfrescoPublicApi; +import org.alfresco.query.EmptyPagingResults; import org.alfresco.query.PagingRequest; import org.alfresco.query.PagingResults; import org.alfresco.service.Auditable; @@ -136,6 +137,24 @@ public interface CategoryService @Auditable(parameters = {"storeRef", "aspectName", "pagingRequest", "sortByName", "filter"}) PagingResults getRootCategories(StoreRef storeRef, QName aspectName, PagingRequest pagingRequest, boolean sortByName, String filter); + /** + * Get a paged list of the root categories for an aspect/classification supporting multiple name filters. + * + * @param storeRef + * @param aspectName + * @param pagingRequest + * @param sortByName + * @param exactNamesFilter + * @param alikeNamesFilter + * @return + */ + @Auditable(parameters = {"storeRef", "aspectName", "pagingRequest", "sortByName", "exactNamesFilter", "alikeNamesFilter"}) + default PagingResults getRootCategories(StoreRef storeRef, QName aspectName, PagingRequest pagingRequest, boolean sortByName, + Collection exactNamesFilter, Collection alikeNamesFilter) + { + return new EmptyPagingResults<>(); + } + /** * Get the root categories for an aspect/classification with names that start with filter * diff --git a/repository/src/main/java/org/alfresco/service/cmr/tagging/TaggingService.java b/repository/src/main/java/org/alfresco/service/cmr/tagging/TaggingService.java index 6035ed710a..9b2eb2b50c 100644 --- a/repository/src/main/java/org/alfresco/service/cmr/tagging/TaggingService.java +++ b/repository/src/main/java/org/alfresco/service/cmr/tagging/TaggingService.java @@ -25,10 +25,12 @@ */ package org.alfresco.service.cmr.tagging; +import java.util.Collection; import java.util.Collections; import java.util.List; -import org.alfresco.api.AlfrescoPublicApi; +import org.alfresco.api.AlfrescoPublicApi; +import org.alfresco.query.EmptyPagingResults; import org.alfresco.query.PagingRequest; import org.alfresco.query.PagingResults; import org.alfresco.service.Auditable; @@ -75,6 +77,21 @@ public interface TaggingService */ @NotAuditable PagingResults> getTags(StoreRef storeRef, PagingRequest pagingRequest); + + /** + * Get a paged list of tags filtered by name + * + * @param storeRef StoreRef + * @param pagingRequest PagingRequest + * @param exactNamesFilter PagingRequest + * @param alikeNamesFilter PagingRequest + * @return PagingResults + */ + @NotAuditable + default PagingResults> getTags(StoreRef storeRef, PagingRequest pagingRequest, Collection exactNamesFilter, Collection alikeNamesFilter) + { + return new EmptyPagingResults<>(); + } /** * Get all the tags currently available that match the provided filter.