@Transactional
    @Cacheable(CACHE_DATABASE_SEARCH)
    @CacheEvict(value = CACHE_AVERAGE_REVIEW_RATING, allEntries = true)
    public SearchHits<ExtensionSearch> search(ISearchService.Options options) {
        // grab all extensions
        var matchingExtensions = repositories.findAllActiveExtensions();

        // no extensions in the database
        if (matchingExtensions.isEmpty()) {
            return new SearchHitsImpl<>(0,TotalHitsRelation.OFF, 0f, null, null, Collections.emptyList(), null, null);
        }

        // exlude namespaces
        if(options.namespacesToExclude != null) {
            for(var namespaceToExclude : options.namespacesToExclude) {
                matchingExtensions = matchingExtensions.filter(extension -> !extension.getNamespace().getName().equals(namespaceToExclude));
            }
        }

        // filter target platform
        if(TargetPlatform.isValid(options.targetPlatform)) {
            matchingExtensions = matchingExtensions.filter(extension -> extension.getVersions().stream().anyMatch(ev -> ev.getTargetPlatform().equals(options.targetPlatform)));
        }

        // filter category
        if (options.category != null) {
            matchingExtensions = matchingExtensions.filter(extension -> {
                var latest = repositories.findLatestVersion(extension, null, false, true);
                return latest.getCategories().stream().anyMatch(category -> category.equalsIgnoreCase(options.category));
            });
        }

        // filter text
        if (options.queryString != null) {
            matchingExtensions = matchingExtensions.filter(extension -> {
                var latest = repositories.findLatestVersion(extension, null, false, true);
                return extension.getName().toLowerCase().contains(options.queryString.toLowerCase())
                    || extension.getNamespace().getName().contains(options.queryString.toLowerCase())
                    || (latest.getDescription() != null && latest.getDescription()
                        .toLowerCase().contains(options.queryString.toLowerCase()))
                    || (latest.getDisplayName() != null && latest.getDisplayName()
                        .toLowerCase().contains(options.queryString.toLowerCase()));
            });
        }

        // need to perform the sortBy ()
        // 'relevance' | 'timestamp' | 'rating' | 'downloadCount';

        Stream<ExtensionSearch> searchEntries;
        if("relevance".equals(options.sortBy) || "rating".equals(options.sortBy)) {
            var searchStats = new SearchStats(repositories);
            searchEntries = matchingExtensions.stream().map(extension -> relevanceService.toSearchEntry(extension, searchStats));
        } else {
            searchEntries = matchingExtensions.stream().map(extension -> {
                var latest = repositories.findLatestVersion(extension, null, false, true);
                var targetPlatforms = repositories.findExtensionTargetPlatforms(extension);
                return extension.toSearch(latest, targetPlatforms);
            });
        }

        var comparators = new HashMap<>(Map.of(
                "relevance", new RelevanceComparator(),
                "timestamp", new TimestampComparator(),
                "rating", new RatingComparator(),
                "downloadCount", new DownloadedCountComparator()
        ));

        var comparator = comparators.get(options.sortBy);
        if(comparator != null) {
            searchEntries = searchEntries.sorted(comparator);
        }

        var sortedExtensions = searchEntries.collect(Collectors.toList());

        // need to do sortOrder
        // 'asc' | 'desc';
        if ("desc".equals(options.sortOrder)) {
            // reverse the order
            Collections.reverse(sortedExtensions);
        }

        // Paging
        var totalHits = sortedExtensions.size();
        var endIndex = Math.min(sortedExtensions.size(), options.requestedOffset + options.requestedSize);
        var startIndex = Math.min(endIndex, options.requestedOffset);
        sortedExtensions = sortedExtensions.subList(startIndex, endIndex);

        List<SearchHit<ExtensionSearch>> searchHits;
        if (sortedExtensions.isEmpty()) {
            searchHits = Collections.emptyList();
        } else {
            // client is interested only in the extension IDs
            searchHits = sortedExtensions.stream().map(extensionSearch -> new SearchHit<>(null, null, null, 0.0f, null, null, null, null, null, null, extensionSearch)).collect(Collectors.toList());
        }

        return new SearchHitsImpl<>(totalHits, TotalHitsRelation.OFF, 0f, null, null, searchHits, null, null);
    }
@Test
    public void testCategory() {
        var ext1 = mockExtension("yaml", 3.0, 100, 0, "redhat", List.of("Snippets", "Programming Languages"));
        var ext2 = mockExtension("java", 4.0, 100, 0, "redhat", List.of("Snippets", "Programming Languages"));
        var ext3 = mockExtension("openshift", 4.0, 100, 0, "redhat", List.of("Snippets", "Other"));
        Mockito.when(repositories.findAllActiveExtensions()).thenReturn(Streamable.of(List.of(ext1, ext2, ext3)));

        var searchOptions = new ISearchService.Options(null, "Programming Languages", TargetPlatform.NAME_UNIVERSAL, 50, 0, null, null, false);
        var result = search.search(searchOptions);
        // should find two extensions
        assertThat(result.getTotalHits()).isEqualTo(2);
    }