From 9a0ec598350df983b6cc1a4ab458e4a4f9644b69 Mon Sep 17 00:00:00 2001 From: algolia-bot Date: Thu, 19 Sep 2024 14:33:19 +0000 Subject: [PATCH] feat(swift): add disjunctive faceting (generated) https://github.com/algolia/api-clients-automation/pull/3778 Co-authored-by: algolia-bot Co-authored-by: Thomas Raffray --- .../Search/Extra/DisjunctiveFaceting.swift | 150 ++++++++++++++ .../Search/Extra/SearchClientExtension.swift | 35 ++++ .../Search/Extra/SearchQueryExtension.swift | 191 ++++++++++++++++++ 3 files changed, 376 insertions(+) create mode 100644 Sources/Search/Extra/DisjunctiveFaceting.swift create mode 100644 Sources/Search/Extra/SearchQueryExtension.swift diff --git a/Sources/Search/Extra/DisjunctiveFaceting.swift b/Sources/Search/Extra/DisjunctiveFaceting.swift new file mode 100644 index 00000000..f44bef37 --- /dev/null +++ b/Sources/Search/Extra/DisjunctiveFaceting.swift @@ -0,0 +1,150 @@ +// +// DisjunctiveFaceting.swift +// AlgoliaSearchClient +// +// Created by Algolia on 18/09/2024. +// + +import Foundation + +public struct SearchDisjunctiveFacetingResponse { + let response: SearchResponse + let disjunctiveFacets: [String: [String: Int]] +} + +/// Helper making multiple queries for disjunctive faceting +/// and merging the multiple search responses into a single one with +/// combined facets information +struct DisjunctiveFacetingHelper { + let query: SearchForHits + let refinements: [String: [String]] + let disjunctiveFacets: Set + + /// Build filters SQL string from the provided refinements and disjunctive facets set + func buildFilters(excluding excludedAttribute: String?) -> String { + String( + self.refinements + .sorted(by: { $0.key < $1.key }) + .filter { (name: String, values: [String]) in + name != excludedAttribute && !values.isEmpty + }.map { (name: String, values: [String]) in + let facetOperator = self.disjunctiveFacets.contains(name) ? " OR " : " AND " + let expression = values + .map { value in """ + "\(name)":"\(value)" + """ + } + .joined(separator: facetOperator) + return "(\(expression))" + }.joined(separator: " AND ") + ) + } + + /// Build search queries to fetch the necessary facets information for disjunctive faceting + /// If the disjunctive facets set is empty, makes a single request with applied conjunctive filters + func makeQueries() -> [SearchQuery] { + var queries = [SearchQuery]() + + var mainQuery = self.query + mainQuery.filters = [ + mainQuery.filters, + self.buildFilters(excluding: .none), + ] + .compactMap { $0 } + .filter { !$0.isEmpty } + .joined(separator: " AND ") + + queries.append(.searchForHits(mainQuery)) + + self.disjunctiveFacets + .sorted(by: { $0 < $1 }) + .forEach { disjunctiveFacet in + var disjunctiveQuery = self.query + disjunctiveQuery.facets = [disjunctiveFacet] + disjunctiveQuery.filters = [ + disjunctiveQuery.filters, + self.buildFilters(excluding: disjunctiveFacet), + ] + .compactMap { $0 } + .filter { !$0.isEmpty } + .joined(separator: " AND ") + disjunctiveQuery.hitsPerPage = 0 + disjunctiveQuery.attributesToRetrieve = [] + disjunctiveQuery.attributesToHighlight = [] + disjunctiveQuery.attributesToSnippet = [] + disjunctiveQuery.analytics = false + queries.append(.searchForHits(disjunctiveQuery)) + } + + return queries + } + + /// Get applied disjunctive facet values for provided attribute + func appliedDisjunctiveFacetValues(for attribute: String) -> Set { + guard self.disjunctiveFacets.contains(attribute) else { + return [] + } + return self.refinements[attribute].flatMap(Set.init) ?? [] + } + + /// Merge received search responses into single one with combined facets information + func mergeResponses( + _ responses: [SearchResponse], + keepSelectedEmptyFacets _: Bool = true + ) throws -> SearchDisjunctiveFacetingResponse { + guard var mainResponse = responses.first else { + throw DisjunctiveFacetingError.emptyResponses + } + + let responsesForDisjunctiveFaceting = responses.dropFirst() + + var mergedDisjunctiveFacets = [String: [String: Int]]() + var mergedFacetStats = mainResponse.facetsStats ?? [:] + var mergedExhaustiveFacetsCount = mainResponse.exhaustive?.facetsCount ?? true + + for result in responsesForDisjunctiveFaceting { + // Merge facet values + if let facetsPerAttribute = result.facets { + for (attribute, facets) in facetsPerAttribute { + // Complete facet values applied in the filters + // but missed in the search response + let missingFacets = self.appliedDisjunctiveFacetValues(for: attribute) + .subtracting(facets.keys) + .reduce(into: [String: Int]()) { acc, cur in acc[cur] = 0 } + mergedDisjunctiveFacets[attribute] = facets.merging(missingFacets) { current, _ in current } + } + } + // Merge facets stats + if let facetsStats = result.facetsStats { + mergedFacetStats.merge(facetsStats) { _, last in last } + } + // If facet counts are not exhaustive, propagate this information to the main results. + // Because disjunctive queries are less restrictive than the main query, it can happen that the main query + // returns exhaustive facet counts, while the disjunctive queries do not. + if let exhaustiveFacetsCount = result.exhaustive?.facetsCount { + mergedExhaustiveFacetsCount = mergedExhaustiveFacetsCount && exhaustiveFacetsCount + } + } + mainResponse.facetsStats = mergedFacetStats + if mainResponse.exhaustive == nil { + mainResponse.exhaustive = SearchExhaustive() + } + mainResponse.exhaustive?.facetsCount = mergedExhaustiveFacetsCount + + return SearchDisjunctiveFacetingResponse( + response: mainResponse, + disjunctiveFacets: mergedDisjunctiveFacets + ) + } +} + +public enum DisjunctiveFacetingError: Error, LocalizedError { + case emptyResponses + + var localizedDescription: String { + switch self { + case .emptyResponses: + "Unexpected empty search responses list. At least one search responses might be present." + } + } +} diff --git a/Sources/Search/Extra/SearchClientExtension.swift b/Sources/Search/Extra/SearchClientExtension.swift index 5862dc8f..7af31728 100644 --- a/Sources/Search/Extra/SearchClientExtension.swift +++ b/Sources/Search/Extra/SearchClientExtension.swift @@ -637,4 +637,39 @@ public extension SearchClient { return true } + + /// Method used for perform search with disjunctive facets. + /// + /// - Parameter indexName: The name of the index in which the search queries should be performed + /// - Parameter searchParamsObject: The search query params. + /// - Parameter refinements: Refinements to apply to the search in form of dictionary with + /// facet attribute as a key and a list of facet values for the designated attribute. + /// Any facet in this list not present in the `disjunctiveFacets` set will be filtered conjunctively. + /// - Parameter disjunctiveFacets: Set of facets attributes applied disjunctively (with OR operator) + /// - Parameter keepSelectedEmptyFacets: Whether the selected facet values might be preserved even + /// in case of their absence in the search response + /// - Parameter requestOptions: Configure request locally with RequestOptions. + /// - Returns: SearchDisjunctiveFacetingResponse - a struct containing the merge response from all the + /// disjunctive faceting search queries, + /// and a list of disjunctive facets + func searchDisjunctiveFaceting( + indexName: String, + searchParamsObject: SearchSearchParamsObject, + refinements: [String: [String]], + disjunctiveFacets: Set, + keepSelectedEmptyFacets: Bool = true, + requestOptions: RequestOptions? = nil + ) async throws -> SearchDisjunctiveFacetingResponse { + let helper = DisjunctiveFacetingHelper( + query: SearchForHits(from: searchParamsObject, indexName: indexName), + refinements: refinements, + disjunctiveFacets: disjunctiveFacets + ) + let queries = helper.makeQueries() + let responses: [SearchResponse] = try await self.searchForHitsWithResponse( + searchMethodParams: SearchMethodParams(requests: queries), + requestOptions: requestOptions + ) + return try helper.mergeResponses(responses, keepSelectedEmptyFacets: keepSelectedEmptyFacets) + } } diff --git a/Sources/Search/Extra/SearchQueryExtension.swift b/Sources/Search/Extra/SearchQueryExtension.swift new file mode 100644 index 00000000..f603d0e1 --- /dev/null +++ b/Sources/Search/Extra/SearchQueryExtension.swift @@ -0,0 +1,191 @@ +// +// SearchQueryExtension.swift +// AlgoliaSearchClient +// +// Created by Algolia on 18/09/2024. +// + +public extension SearchQuery { + init(from searchParamsObject: SearchSearchParamsObject, options: SearchForHitsOptions, params: String? = nil) { + self = .searchForHits(SearchForHits(from: searchParamsObject, indexName: options.indexName, params: params)) + } + + init(from searchParamsObject: SearchSearchParamsObject, options: SearchForFacetsOptions, params: String? = nil) { + self = .searchForFacets(SearchForFacets(from: searchParamsObject, options: options, params: params)) + } +} + +public extension SearchForHits { + init(from searchParamsObject: SearchSearchParamsObject, indexName: String, params: String? = nil) { + self.params = params + self.query = searchParamsObject.query + self.similarQuery = searchParamsObject.similarQuery + self.filters = searchParamsObject.filters + self.facetFilters = searchParamsObject.facetFilters + self.optionalFilters = searchParamsObject.optionalFilters + self.numericFilters = searchParamsObject.numericFilters + self.tagFilters = searchParamsObject.tagFilters + self.sumOrFiltersScores = searchParamsObject.sumOrFiltersScores + self.restrictSearchableAttributes = searchParamsObject.restrictSearchableAttributes + self.facets = searchParamsObject.facets + self.facetingAfterDistinct = searchParamsObject.facetingAfterDistinct + self.page = searchParamsObject.page + self.offset = searchParamsObject.offset + self.length = searchParamsObject.length + self.aroundLatLng = searchParamsObject.aroundLatLng + self.aroundLatLngViaIP = searchParamsObject.aroundLatLngViaIP + self.aroundRadius = searchParamsObject.aroundRadius + self.aroundPrecision = searchParamsObject.aroundPrecision + self.minimumAroundRadius = searchParamsObject.minimumAroundRadius + self.insideBoundingBox = searchParamsObject.insideBoundingBox + self.insidePolygon = searchParamsObject.insidePolygon + self.naturalLanguages = searchParamsObject.naturalLanguages + self.ruleContexts = searchParamsObject.ruleContexts + self.personalizationImpact = searchParamsObject.personalizationImpact + self.userToken = searchParamsObject.userToken + self.getRankingInfo = searchParamsObject.getRankingInfo + self.synonyms = searchParamsObject.synonyms + self.clickAnalytics = searchParamsObject.clickAnalytics + self.analytics = searchParamsObject.analytics + self.analyticsTags = searchParamsObject.analyticsTags + self.percentileComputation = searchParamsObject.percentileComputation + self.enableABTest = searchParamsObject.enableABTest + self.attributesToRetrieve = searchParamsObject.attributesToRetrieve + self.ranking = searchParamsObject.ranking + self.customRanking = searchParamsObject.customRanking + self.relevancyStrictness = searchParamsObject.relevancyStrictness + self.attributesToHighlight = searchParamsObject.attributesToHighlight + self.attributesToSnippet = searchParamsObject.attributesToSnippet + self.highlightPreTag = searchParamsObject.highlightPreTag + self.highlightPostTag = searchParamsObject.highlightPostTag + self.snippetEllipsisText = searchParamsObject.snippetEllipsisText + self.restrictHighlightAndSnippetArrays = searchParamsObject.restrictHighlightAndSnippetArrays + self.hitsPerPage = searchParamsObject.hitsPerPage + self.minWordSizefor1Typo = searchParamsObject.minWordSizefor1Typo + self.minWordSizefor2Typos = searchParamsObject.minWordSizefor2Typos + self.typoTolerance = searchParamsObject.typoTolerance + self.allowTyposOnNumericTokens = searchParamsObject.allowTyposOnNumericTokens + self.disableTypoToleranceOnAttributes = searchParamsObject.disableTypoToleranceOnAttributes + self.ignorePlurals = searchParamsObject.ignorePlurals + self.removeStopWords = searchParamsObject.removeStopWords + self.keepDiacriticsOnCharacters = searchParamsObject.keepDiacriticsOnCharacters + self.queryLanguages = searchParamsObject.queryLanguages + self.decompoundQuery = searchParamsObject.decompoundQuery + self.enableRules = searchParamsObject.enableRules + self.enablePersonalization = searchParamsObject.enablePersonalization + self.queryType = searchParamsObject.queryType + self.removeWordsIfNoResults = searchParamsObject.removeWordsIfNoResults + self.mode = searchParamsObject.mode + self.semanticSearch = searchParamsObject.semanticSearch + self.advancedSyntax = searchParamsObject.advancedSyntax + self.optionalWords = searchParamsObject.optionalWords + self.disableExactOnAttributes = searchParamsObject.disableExactOnAttributes + self.exactOnSingleWordQuery = searchParamsObject.exactOnSingleWordQuery + self.alternativesAsExact = searchParamsObject.alternativesAsExact + self.advancedSyntaxFeatures = searchParamsObject.advancedSyntaxFeatures + self.distinct = searchParamsObject.distinct + self.replaceSynonymsInHighlight = searchParamsObject.replaceSynonymsInHighlight + self.minProximity = searchParamsObject.minProximity + self.responseFields = searchParamsObject.responseFields + self.maxFacetHits = searchParamsObject.maxFacetHits + self.maxValuesPerFacet = searchParamsObject.maxValuesPerFacet + self.sortFacetValuesBy = searchParamsObject.sortFacetValuesBy + self.attributeCriteriaComputedByMinProximity = searchParamsObject.attributeCriteriaComputedByMinProximity + self.renderingContent = searchParamsObject.renderingContent + self.enableReRanking = searchParamsObject.enableReRanking + self.reRankingApplyFilter = searchParamsObject.reRankingApplyFilter + self.indexName = indexName + self.type = .default + } + + init(from searchParamsObject: SearchSearchParamsObject, options: SearchForHitsOptions, params: String? = nil) { + self = .init(from: searchParamsObject, indexName: options.indexName, params: params) + } +} + +public extension SearchForFacets { + init(from searchParamsObject: SearchSearchParamsObject, options: SearchForFacetsOptions, params: String? = nil) { + self.params = params + self.query = searchParamsObject.query + self.similarQuery = searchParamsObject.similarQuery + self.filters = searchParamsObject.filters + self.facetFilters = searchParamsObject.facetFilters + self.optionalFilters = searchParamsObject.optionalFilters + self.numericFilters = searchParamsObject.numericFilters + self.tagFilters = searchParamsObject.tagFilters + self.sumOrFiltersScores = searchParamsObject.sumOrFiltersScores + self.restrictSearchableAttributes = searchParamsObject.restrictSearchableAttributes + self.facets = searchParamsObject.facets + self.facetingAfterDistinct = searchParamsObject.facetingAfterDistinct + self.page = searchParamsObject.page + self.offset = searchParamsObject.offset + self.length = searchParamsObject.length + self.aroundLatLng = searchParamsObject.aroundLatLng + self.aroundLatLngViaIP = searchParamsObject.aroundLatLngViaIP + self.aroundRadius = searchParamsObject.aroundRadius + self.aroundPrecision = searchParamsObject.aroundPrecision + self.minimumAroundRadius = searchParamsObject.minimumAroundRadius + self.insideBoundingBox = searchParamsObject.insideBoundingBox + self.insidePolygon = searchParamsObject.insidePolygon + self.naturalLanguages = searchParamsObject.naturalLanguages + self.ruleContexts = searchParamsObject.ruleContexts + self.personalizationImpact = searchParamsObject.personalizationImpact + self.userToken = searchParamsObject.userToken + self.getRankingInfo = searchParamsObject.getRankingInfo + self.synonyms = searchParamsObject.synonyms + self.clickAnalytics = searchParamsObject.clickAnalytics + self.analytics = searchParamsObject.analytics + self.analyticsTags = searchParamsObject.analyticsTags + self.percentileComputation = searchParamsObject.percentileComputation + self.enableABTest = searchParamsObject.enableABTest + self.attributesToRetrieve = searchParamsObject.attributesToRetrieve + self.ranking = searchParamsObject.ranking + self.customRanking = searchParamsObject.customRanking + self.relevancyStrictness = searchParamsObject.relevancyStrictness + self.attributesToHighlight = searchParamsObject.attributesToHighlight + self.attributesToSnippet = searchParamsObject.attributesToSnippet + self.highlightPreTag = searchParamsObject.highlightPreTag + self.highlightPostTag = searchParamsObject.highlightPostTag + self.snippetEllipsisText = searchParamsObject.snippetEllipsisText + self.restrictHighlightAndSnippetArrays = searchParamsObject.restrictHighlightAndSnippetArrays + self.hitsPerPage = searchParamsObject.hitsPerPage + self.minWordSizefor1Typo = searchParamsObject.minWordSizefor1Typo + self.minWordSizefor2Typos = searchParamsObject.minWordSizefor2Typos + self.typoTolerance = searchParamsObject.typoTolerance + self.allowTyposOnNumericTokens = searchParamsObject.allowTyposOnNumericTokens + self.disableTypoToleranceOnAttributes = searchParamsObject.disableTypoToleranceOnAttributes + self.ignorePlurals = searchParamsObject.ignorePlurals + self.removeStopWords = searchParamsObject.removeStopWords + self.keepDiacriticsOnCharacters = searchParamsObject.keepDiacriticsOnCharacters + self.queryLanguages = searchParamsObject.queryLanguages + self.decompoundQuery = searchParamsObject.decompoundQuery + self.enableRules = searchParamsObject.enableRules + self.enablePersonalization = searchParamsObject.enablePersonalization + self.queryType = searchParamsObject.queryType + self.removeWordsIfNoResults = searchParamsObject.removeWordsIfNoResults + self.mode = searchParamsObject.mode + self.semanticSearch = searchParamsObject.semanticSearch + self.advancedSyntax = searchParamsObject.advancedSyntax + self.optionalWords = searchParamsObject.optionalWords + self.disableExactOnAttributes = searchParamsObject.disableExactOnAttributes + self.exactOnSingleWordQuery = searchParamsObject.exactOnSingleWordQuery + self.alternativesAsExact = searchParamsObject.alternativesAsExact + self.advancedSyntaxFeatures = searchParamsObject.advancedSyntaxFeatures + self.distinct = searchParamsObject.distinct + self.replaceSynonymsInHighlight = searchParamsObject.replaceSynonymsInHighlight + self.minProximity = searchParamsObject.minProximity + self.responseFields = searchParamsObject.responseFields + self.maxFacetHits = searchParamsObject.maxFacetHits + self.maxValuesPerFacet = searchParamsObject.maxValuesPerFacet + self.sortFacetValuesBy = searchParamsObject.sortFacetValuesBy + self.attributeCriteriaComputedByMinProximity = searchParamsObject.attributeCriteriaComputedByMinProximity + self.renderingContent = searchParamsObject.renderingContent + self.enableReRanking = searchParamsObject.enableReRanking + self.reRankingApplyFilter = searchParamsObject.reRankingApplyFilter + self.facet = options.facet + self.indexName = options.indexName + self.facetQuery = options.facetQuery + self.maxFacetHits = options.maxFacetHits + self.type = .facet + } +}