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 864fb27276..23842943e7 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 @@ -2,7 +2,7 @@ * #%L * Alfresco Repository * %% - * Copyright (C) 2005 - 2020 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 @@ -32,6 +32,7 @@ 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; @@ -44,7 +45,10 @@ 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; @@ -546,6 +550,58 @@ public abstract class AbstractCategoryServiceImpl implements CategoryService 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) diff --git a/repository/src/main/java/org/alfresco/repo/search/impl/solr/SolrCategoryServiceImpl.java b/repository/src/main/java/org/alfresco/repo/search/impl/solr/SolrCategoryServiceImpl.java index 7981302dd2..632b34dcec 100644 --- a/repository/src/main/java/org/alfresco/repo/search/impl/solr/SolrCategoryServiceImpl.java +++ b/repository/src/main/java/org/alfresco/repo/search/impl/solr/SolrCategoryServiceImpl.java @@ -2,7 +2,7 @@ * #%L * Alfresco Repository * %% - * Copyright (C) 2005 - 2020 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 @@ -27,12 +27,8 @@ package org.alfresco.repo.search.impl.solr; import java.util.LinkedList; import java.util.List; -import java.util.Map; import org.alfresco.repo.search.impl.AbstractCategoryServiceImpl; -import org.alfresco.service.cmr.dictionary.AspectDefinition; -import org.alfresco.service.cmr.dictionary.DataTypeDefinition; -import org.alfresco.service.cmr.dictionary.PropertyDefinition; import org.alfresco.service.cmr.repository.NodeRef; import org.alfresco.service.cmr.repository.StoreRef; import org.alfresco.service.cmr.search.ResultSet; @@ -52,59 +48,30 @@ public class SolrCategoryServiceImpl extends AbstractCategoryServiceImpl @Override public List> getTopCategories(StoreRef storeRef, QName aspectName, int count) { - AspectDefinition definition = dictionaryService.getAspect(aspectName); - if(definition == null) - { - throw new IllegalStateException("Unknown aspect"); - } - QName catProperty = null; - Map properties = definition.getProperties(); - for(QName pName : properties.keySet()) - { - if(pName.getNamespaceURI().equals(aspectName.getNamespaceURI())) - { - if(pName.getLocalName().equalsIgnoreCase(aspectName.getLocalName())) - { - PropertyDefinition def = properties.get(pName); - if(def.getDataType().getName().equals(DataTypeDefinition.CATEGORY)) - { - catProperty = pName; - } - } - } - } - if(catProperty == null) - { - throw new IllegalStateException("Aspect does not have category property mirroring the aspect name"); - } - - String field = "@" + catProperty; - - SearchParameters sp = new SearchParameters(); - sp.setLanguage(SearchService.LANGUAGE_INDEX_FTS_ALFRESCO); - sp.addStore(storeRef); - sp.setQuery(catProperty+":*"); - FieldFacet ff = new FieldFacet(field); - ff.setLimitOrNull(count); - sp.addFieldFacet(ff); + final SearchParameters searchParameters = createSearchTopCategoriesParameters(storeRef, aspectName, count); + searchParameters.setLanguage(SearchService.LANGUAGE_INDEX_FTS_ALFRESCO); + final String field = searchParameters.getFieldFacets().stream() + .map(FieldFacet::getField) + .findFirst() + .orElse(""); ResultSet resultSet = null; try { - resultSet = indexerAndSearcher.getSearcher(storeRef, false).query(sp); - List> facetCounts = resultSet.getFieldFacet(field); - List> answer = new LinkedList>(); + resultSet = indexerAndSearcher.getSearcher(storeRef, false).query(searchParameters); + final List> facetCounts = resultSet.getFieldFacet(field); + final List> answer = new LinkedList<>(); for (Pair term : facetCounts) { Pair toAdd; - NodeRef nodeRef = new NodeRef(term.getFirst()); + final NodeRef nodeRef = new NodeRef(term.getFirst()); if (nodeService.exists(nodeRef)) { - toAdd = new Pair(nodeRef, term.getSecond()); + toAdd = new Pair<>(nodeRef, term.getSecond()); } else { - toAdd = new Pair(null, term.getSecond()); + toAdd = new Pair<>(null, term.getSecond()); } answer.add(toAdd); } diff --git a/repository/src/test/java/org/alfresco/repo/search/impl/solr/SolrCategoryServiceImplTest.java b/repository/src/test/java/org/alfresco/repo/search/impl/solr/SolrCategoryServiceImplTest.java index 5d62ca5b85..4d0be02653 100644 --- a/repository/src/test/java/org/alfresco/repo/search/impl/solr/SolrCategoryServiceImplTest.java +++ b/repository/src/test/java/org/alfresco/repo/search/impl/solr/SolrCategoryServiceImplTest.java @@ -2,7 +2,7 @@ * #%L * Alfresco Remote API * %% - * Copyright (C) 2005 - 2022 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 @@ -26,20 +26,39 @@ package org.alfresco.repo.search.impl.solr; -import static org.junit.Assert.*; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; +import static org.mockito.ArgumentMatchers.any; import static org.mockito.BDDMockito.given; import static org.mockito.BDDMockito.then; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; import java.util.List; +import java.util.Map; import java.util.Optional; import java.util.Set; +import java.util.stream.Collectors; +import java.util.stream.IntStream; import org.alfresco.model.ContentModel; -import org.alfresco.repo.search.impl.noindex.NoIndexCategoryServiceImpl; +import org.alfresco.repo.search.IndexerAndSearcher; +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.NodeRef; import org.alfresco.service.cmr.repository.NodeService; import org.alfresco.service.cmr.repository.StoreRef; +import org.alfresco.service.cmr.search.ResultSet; +import org.alfresco.service.cmr.search.SearchParameters; +import org.alfresco.service.cmr.search.SearchService; +import org.alfresco.service.namespace.NamespaceService; +import org.alfresco.service.namespace.QName; +import org.alfresco.util.Pair; +import org.apache.commons.collections.CollectionUtils; import org.junit.Test; import org.junit.runner.RunWith; import org.mockito.InjectMocks; @@ -55,6 +74,8 @@ public class SolrCategoryServiceImplTest { private static final String PATH_ROOT = "-root-"; private static final String CAT_ROOT_NODE_ID = "cat-root-node-id"; + private static final String NODE_ID_PREFIX = "node-id-"; + private static final StoreRef STORE_REF = StoreRef.STORE_REF_WORKSPACE_SPACESSTORE; @Mock private NodeService nodeServiceMock; @@ -62,9 +83,19 @@ public class SolrCategoryServiceImplTest private ChildAssociationRef categoryRootChildAssociationRefMock; @Mock private ChildAssociationRef categoryChildAssociationRefMock; + @Mock + private IndexerAndSearcher indexerAndSearcherMock; + @Mock + private SearchService searcherMock; + @Mock + private DictionaryService dictionaryServiceMock; + @Mock + private AspectDefinition aspectDefinitionMock; + @Mock + private ResultSet resultSetMock; @InjectMocks - private NoIndexCategoryServiceImpl objectUnderTest; + private SolrCategoryServiceImpl objectUnderTest; @Test public void testGetRootCategoryNodeRef() @@ -90,4 +121,150 @@ public class SolrCategoryServiceImplTest assertTrue(rooCategoryNodeRef.isPresent()); assertEquals(CAT_ROOT_NODE_ID, rooCategoryNodeRef.get().getId()); } + + @Test + public void testGetTopCategories() + { + given(indexerAndSearcherMock.getSearcher(STORE_REF, false)).willReturn(searcherMock); + final QName aspectGenClassifiable = ContentModel.ASPECT_GEN_CLASSIFIABLE; + mockAspectDefinition(ContentModel.PROP_CATEGORIES, null); + given(dictionaryServiceMock.getAspect(aspectGenClassifiable)).willReturn(aspectDefinitionMock); + final QName categoryProperty = ContentModel.PROP_CATEGORIES; + final String field = getField(categoryProperty); + final int count = 100; + final SearchParameters searchParameters = prepareSearchParams(STORE_REF, categoryProperty, field, count); + final List countList = List.of(11, 9, 8); + mockResultSet(field, countList); + given(searcherMock.query(searchParameters)).willReturn(resultSetMock); + given(nodeServiceMock.exists(any(NodeRef.class))).willReturn(true); + + //when + final List> topCategories = objectUnderTest.getTopCategories(STORE_REF, aspectGenClassifiable, count); + + then(indexerAndSearcherMock).should().getSearcher(STORE_REF, false); + then(indexerAndSearcherMock).shouldHaveNoMoreInteractions(); + then(dictionaryServiceMock).should().getAspect(aspectGenClassifiable); + then(dictionaryServiceMock).shouldHaveNoMoreInteractions(); + then(searcherMock).should().query(searchParameters); + then(searcherMock).shouldHaveNoMoreInteractions(); + then(nodeServiceMock).should(times(3)).exists(any(NodeRef.class)); + then(nodeServiceMock).shouldHaveNoMoreInteractions(); + + IntStream.range(0, countList.size()) + .forEach(i -> { + final NodeRef nodeRef = new NodeRef(StoreRef.STORE_REF_WORKSPACE_SPACESSTORE, NODE_ID_PREFIX + i); + assertEquals(nodeRef, topCategories.get(i).getFirst()); + assertEquals(countList.get(i), topCategories.get(i).getSecond()); + }); + } + + @Test + public void testGetTopCategories_nonExistingAspect() + { + final QName aspectGenClassifiable = ContentModel.ASPECT_GEN_CLASSIFIABLE; + given(dictionaryServiceMock.getAspect(aspectGenClassifiable)).willReturn(null); + + //when + assertThrows(IllegalStateException.class, () -> objectUnderTest.getTopCategories(STORE_REF, aspectGenClassifiable, 100)); + + then(indexerAndSearcherMock).shouldHaveNoInteractions(); + then(dictionaryServiceMock).should().getAspect(aspectGenClassifiable); + then(dictionaryServiceMock).shouldHaveNoMoreInteractions(); + then(searcherMock).shouldHaveNoInteractions(); + then(nodeServiceMock).shouldHaveNoInteractions(); + } + + @Test + public void testGetTopCategories_customCategoryAspect() + { + given(indexerAndSearcherMock.getSearcher(STORE_REF, false)).willReturn(searcherMock); + final QName aspectCustomCategories = QName.createQName(NamespaceService.CONTENT_MODEL_1_0_URI, "customcategories"); + mockAspectDefinition(aspectCustomCategories, DataTypeDefinition.CATEGORY); + given(dictionaryServiceMock.getAspect(aspectCustomCategories)).willReturn(aspectDefinitionMock); + final String field = getField(aspectCustomCategories); + final int count = 100; + final SearchParameters searchParameters = prepareSearchParams(STORE_REF, aspectCustomCategories, field, count); + final List countList = List.of(11, 9, 8); + mockResultSet(field, countList); + given(searcherMock.query(searchParameters)).willReturn(resultSetMock); + given(nodeServiceMock.exists(any(NodeRef.class))).willReturn(true); + + //when + final List> topCategories = objectUnderTest.getTopCategories(STORE_REF, aspectCustomCategories, count); + + then(indexerAndSearcherMock).should().getSearcher(STORE_REF, false); + then(indexerAndSearcherMock).shouldHaveNoMoreInteractions(); + then(dictionaryServiceMock).should().getAspect(aspectCustomCategories); + then(dictionaryServiceMock).shouldHaveNoMoreInteractions(); + then(searcherMock).should().query(searchParameters); + then(searcherMock).shouldHaveNoMoreInteractions(); + then(nodeServiceMock).should(times(3)).exists(any(NodeRef.class)); + then(nodeServiceMock).shouldHaveNoMoreInteractions(); + + IntStream.range(0, countList.size()) + .forEach(i -> { + final NodeRef nodeRef = new NodeRef(StoreRef.STORE_REF_WORKSPACE_SPACESSTORE, NODE_ID_PREFIX + i); + assertEquals(nodeRef, topCategories.get(i).getFirst()); + assertEquals(countList.get(i), topCategories.get(i).getSecond()); + }); + } + + @Test + public void testGetTopCategories_invalidCustomCategoryAspect() + { + final QName aspectCustomCategories = QName.createQName(NamespaceService.CONTENT_MODEL_1_0_URI, "customcategories"); + mockAspectDefinition(aspectCustomCategories, DataTypeDefinition.QNAME); + given(dictionaryServiceMock.getAspect(aspectCustomCategories)).willReturn(aspectDefinitionMock); + final String field = getField(aspectCustomCategories); + final int count = 100; + + //when + assertThrows(IllegalStateException.class, () -> objectUnderTest.getTopCategories(STORE_REF, aspectCustomCategories, count)); + + then(indexerAndSearcherMock).shouldHaveNoInteractions(); + then(dictionaryServiceMock).should().getAspect(aspectCustomCategories); + then(dictionaryServiceMock).shouldHaveNoMoreInteractions(); + then(searcherMock).shouldHaveNoInteractions(); + then(nodeServiceMock).shouldHaveNoInteractions(); + } + + private String getField(QName categoryProperty) + { + return "@" + categoryProperty; + } + + private SearchParameters prepareSearchParams(StoreRef storeRef, QName categoryProperty, String field, int count) + { + final SearchParameters sp = new SearchParameters(); + sp.setLanguage(SearchService.LANGUAGE_INDEX_FTS_ALFRESCO); + sp.addStore(storeRef); + sp.setQuery(categoryProperty + ":*"); + final SearchParameters.FieldFacet ff = new SearchParameters.FieldFacet(field); + ff.setLimitOrNull(count); + sp.addFieldFacet(ff); + sp.setMaxItems(1); + sp.setSkipCount(0); + return sp; + } + + private void mockResultSet(final String field, List countList) + { + final List> facetedResults = IntStream.range(0, countList.size()) + .mapToObj(i -> new Pair<>(StoreRef.STORE_REF_WORKSPACE_SPACESSTORE + "/" + NODE_ID_PREFIX + i, countList.get(i))) + .collect(Collectors.toList()); + given(resultSetMock.getFieldFacet(field)).willReturn(CollectionUtils.isEmpty(facetedResults) ? null : facetedResults); + } + + private void mockAspectDefinition(final QName qName, final QName dataType) + { + final PropertyDefinition propertyDefinitionMock = mock(PropertyDefinition.class); + final DataTypeDefinition dataTypeMock = mock(DataTypeDefinition.class); + if (dataType != null) + { + given(propertyDefinitionMock.getDataType()).willReturn(dataTypeMock); + given(dataTypeMock.getName()).willReturn(dataType); + } + given(aspectDefinitionMock.getProperties()).willReturn(Map.of(qName, propertyDefinitionMock)); + } + }