Skip to content

Commit 9a0ec59

Browse files
algolia-botFluf22
andcommitted
feat(swift): add disjunctive faceting (generated)
algolia/api-clients-automation#3778 Co-authored-by: algolia-bot <[email protected]> Co-authored-by: Thomas Raffray <[email protected]>
1 parent 65a555a commit 9a0ec59

File tree

3 files changed

+376
-0
lines changed

3 files changed

+376
-0
lines changed
Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
1+
//
2+
// DisjunctiveFaceting.swift
3+
// AlgoliaSearchClient
4+
//
5+
// Created by Algolia on 18/09/2024.
6+
//
7+
8+
import Foundation
9+
10+
public struct SearchDisjunctiveFacetingResponse<T: Codable> {
11+
let response: SearchResponse<T>
12+
let disjunctiveFacets: [String: [String: Int]]
13+
}
14+
15+
/// Helper making multiple queries for disjunctive faceting
16+
/// and merging the multiple search responses into a single one with
17+
/// combined facets information
18+
struct DisjunctiveFacetingHelper {
19+
let query: SearchForHits
20+
let refinements: [String: [String]]
21+
let disjunctiveFacets: Set<String>
22+
23+
/// Build filters SQL string from the provided refinements and disjunctive facets set
24+
func buildFilters(excluding excludedAttribute: String?) -> String {
25+
String(
26+
self.refinements
27+
.sorted(by: { $0.key < $1.key })
28+
.filter { (name: String, values: [String]) in
29+
name != excludedAttribute && !values.isEmpty
30+
}.map { (name: String, values: [String]) in
31+
let facetOperator = self.disjunctiveFacets.contains(name) ? " OR " : " AND "
32+
let expression = values
33+
.map { value in """
34+
"\(name)":"\(value)"
35+
"""
36+
}
37+
.joined(separator: facetOperator)
38+
return "(\(expression))"
39+
}.joined(separator: " AND ")
40+
)
41+
}
42+
43+
/// Build search queries to fetch the necessary facets information for disjunctive faceting
44+
/// If the disjunctive facets set is empty, makes a single request with applied conjunctive filters
45+
func makeQueries() -> [SearchQuery] {
46+
var queries = [SearchQuery]()
47+
48+
var mainQuery = self.query
49+
mainQuery.filters = [
50+
mainQuery.filters,
51+
self.buildFilters(excluding: .none),
52+
]
53+
.compactMap { $0 }
54+
.filter { !$0.isEmpty }
55+
.joined(separator: " AND ")
56+
57+
queries.append(.searchForHits(mainQuery))
58+
59+
self.disjunctiveFacets
60+
.sorted(by: { $0 < $1 })
61+
.forEach { disjunctiveFacet in
62+
var disjunctiveQuery = self.query
63+
disjunctiveQuery.facets = [disjunctiveFacet]
64+
disjunctiveQuery.filters = [
65+
disjunctiveQuery.filters,
66+
self.buildFilters(excluding: disjunctiveFacet),
67+
]
68+
.compactMap { $0 }
69+
.filter { !$0.isEmpty }
70+
.joined(separator: " AND ")
71+
disjunctiveQuery.hitsPerPage = 0
72+
disjunctiveQuery.attributesToRetrieve = []
73+
disjunctiveQuery.attributesToHighlight = []
74+
disjunctiveQuery.attributesToSnippet = []
75+
disjunctiveQuery.analytics = false
76+
queries.append(.searchForHits(disjunctiveQuery))
77+
}
78+
79+
return queries
80+
}
81+
82+
/// Get applied disjunctive facet values for provided attribute
83+
func appliedDisjunctiveFacetValues(for attribute: String) -> Set<String> {
84+
guard self.disjunctiveFacets.contains(attribute) else {
85+
return []
86+
}
87+
return self.refinements[attribute].flatMap(Set.init) ?? []
88+
}
89+
90+
/// Merge received search responses into single one with combined facets information
91+
func mergeResponses<T: Codable>(
92+
_ responses: [SearchResponse<T>],
93+
keepSelectedEmptyFacets _: Bool = true
94+
) throws -> SearchDisjunctiveFacetingResponse<T> {
95+
guard var mainResponse = responses.first else {
96+
throw DisjunctiveFacetingError.emptyResponses
97+
}
98+
99+
let responsesForDisjunctiveFaceting = responses.dropFirst()
100+
101+
var mergedDisjunctiveFacets = [String: [String: Int]]()
102+
var mergedFacetStats = mainResponse.facetsStats ?? [:]
103+
var mergedExhaustiveFacetsCount = mainResponse.exhaustive?.facetsCount ?? true
104+
105+
for result in responsesForDisjunctiveFaceting {
106+
// Merge facet values
107+
if let facetsPerAttribute = result.facets {
108+
for (attribute, facets) in facetsPerAttribute {
109+
// Complete facet values applied in the filters
110+
// but missed in the search response
111+
let missingFacets = self.appliedDisjunctiveFacetValues(for: attribute)
112+
.subtracting(facets.keys)
113+
.reduce(into: [String: Int]()) { acc, cur in acc[cur] = 0 }
114+
mergedDisjunctiveFacets[attribute] = facets.merging(missingFacets) { current, _ in current }
115+
}
116+
}
117+
// Merge facets stats
118+
if let facetsStats = result.facetsStats {
119+
mergedFacetStats.merge(facetsStats) { _, last in last }
120+
}
121+
// If facet counts are not exhaustive, propagate this information to the main results.
122+
// Because disjunctive queries are less restrictive than the main query, it can happen that the main query
123+
// returns exhaustive facet counts, while the disjunctive queries do not.
124+
if let exhaustiveFacetsCount = result.exhaustive?.facetsCount {
125+
mergedExhaustiveFacetsCount = mergedExhaustiveFacetsCount && exhaustiveFacetsCount
126+
}
127+
}
128+
mainResponse.facetsStats = mergedFacetStats
129+
if mainResponse.exhaustive == nil {
130+
mainResponse.exhaustive = SearchExhaustive()
131+
}
132+
mainResponse.exhaustive?.facetsCount = mergedExhaustiveFacetsCount
133+
134+
return SearchDisjunctiveFacetingResponse(
135+
response: mainResponse,
136+
disjunctiveFacets: mergedDisjunctiveFacets
137+
)
138+
}
139+
}
140+
141+
public enum DisjunctiveFacetingError: Error, LocalizedError {
142+
case emptyResponses
143+
144+
var localizedDescription: String {
145+
switch self {
146+
case .emptyResponses:
147+
"Unexpected empty search responses list. At least one search responses might be present."
148+
}
149+
}
150+
}

Sources/Search/Extra/SearchClientExtension.swift

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -637,4 +637,39 @@ public extension SearchClient {
637637

638638
return true
639639
}
640+
641+
/// Method used for perform search with disjunctive facets.
642+
///
643+
/// - Parameter indexName: The name of the index in which the search queries should be performed
644+
/// - Parameter searchParamsObject: The search query params.
645+
/// - Parameter refinements: Refinements to apply to the search in form of dictionary with
646+
/// facet attribute as a key and a list of facet values for the designated attribute.
647+
/// Any facet in this list not present in the `disjunctiveFacets` set will be filtered conjunctively.
648+
/// - Parameter disjunctiveFacets: Set of facets attributes applied disjunctively (with OR operator)
649+
/// - Parameter keepSelectedEmptyFacets: Whether the selected facet values might be preserved even
650+
/// in case of their absence in the search response
651+
/// - Parameter requestOptions: Configure request locally with RequestOptions.
652+
/// - Returns: SearchDisjunctiveFacetingResponse<T> - a struct containing the merge response from all the
653+
/// disjunctive faceting search queries,
654+
/// and a list of disjunctive facets
655+
func searchDisjunctiveFaceting<T: Codable>(
656+
indexName: String,
657+
searchParamsObject: SearchSearchParamsObject,
658+
refinements: [String: [String]],
659+
disjunctiveFacets: Set<String>,
660+
keepSelectedEmptyFacets: Bool = true,
661+
requestOptions: RequestOptions? = nil
662+
) async throws -> SearchDisjunctiveFacetingResponse<T> {
663+
let helper = DisjunctiveFacetingHelper(
664+
query: SearchForHits(from: searchParamsObject, indexName: indexName),
665+
refinements: refinements,
666+
disjunctiveFacets: disjunctiveFacets
667+
)
668+
let queries = helper.makeQueries()
669+
let responses: [SearchResponse<T>] = try await self.searchForHitsWithResponse(
670+
searchMethodParams: SearchMethodParams(requests: queries),
671+
requestOptions: requestOptions
672+
)
673+
return try helper.mergeResponses(responses, keepSelectedEmptyFacets: keepSelectedEmptyFacets)
674+
}
640675
}
Lines changed: 191 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,191 @@
1+
//
2+
// SearchQueryExtension.swift
3+
// AlgoliaSearchClient
4+
//
5+
// Created by Algolia on 18/09/2024.
6+
//
7+
8+
public extension SearchQuery {
9+
init(from searchParamsObject: SearchSearchParamsObject, options: SearchForHitsOptions, params: String? = nil) {
10+
self = .searchForHits(SearchForHits(from: searchParamsObject, indexName: options.indexName, params: params))
11+
}
12+
13+
init(from searchParamsObject: SearchSearchParamsObject, options: SearchForFacetsOptions, params: String? = nil) {
14+
self = .searchForFacets(SearchForFacets(from: searchParamsObject, options: options, params: params))
15+
}
16+
}
17+
18+
public extension SearchForHits {
19+
init(from searchParamsObject: SearchSearchParamsObject, indexName: String, params: String? = nil) {
20+
self.params = params
21+
self.query = searchParamsObject.query
22+
self.similarQuery = searchParamsObject.similarQuery
23+
self.filters = searchParamsObject.filters
24+
self.facetFilters = searchParamsObject.facetFilters
25+
self.optionalFilters = searchParamsObject.optionalFilters
26+
self.numericFilters = searchParamsObject.numericFilters
27+
self.tagFilters = searchParamsObject.tagFilters
28+
self.sumOrFiltersScores = searchParamsObject.sumOrFiltersScores
29+
self.restrictSearchableAttributes = searchParamsObject.restrictSearchableAttributes
30+
self.facets = searchParamsObject.facets
31+
self.facetingAfterDistinct = searchParamsObject.facetingAfterDistinct
32+
self.page = searchParamsObject.page
33+
self.offset = searchParamsObject.offset
34+
self.length = searchParamsObject.length
35+
self.aroundLatLng = searchParamsObject.aroundLatLng
36+
self.aroundLatLngViaIP = searchParamsObject.aroundLatLngViaIP
37+
self.aroundRadius = searchParamsObject.aroundRadius
38+
self.aroundPrecision = searchParamsObject.aroundPrecision
39+
self.minimumAroundRadius = searchParamsObject.minimumAroundRadius
40+
self.insideBoundingBox = searchParamsObject.insideBoundingBox
41+
self.insidePolygon = searchParamsObject.insidePolygon
42+
self.naturalLanguages = searchParamsObject.naturalLanguages
43+
self.ruleContexts = searchParamsObject.ruleContexts
44+
self.personalizationImpact = searchParamsObject.personalizationImpact
45+
self.userToken = searchParamsObject.userToken
46+
self.getRankingInfo = searchParamsObject.getRankingInfo
47+
self.synonyms = searchParamsObject.synonyms
48+
self.clickAnalytics = searchParamsObject.clickAnalytics
49+
self.analytics = searchParamsObject.analytics
50+
self.analyticsTags = searchParamsObject.analyticsTags
51+
self.percentileComputation = searchParamsObject.percentileComputation
52+
self.enableABTest = searchParamsObject.enableABTest
53+
self.attributesToRetrieve = searchParamsObject.attributesToRetrieve
54+
self.ranking = searchParamsObject.ranking
55+
self.customRanking = searchParamsObject.customRanking
56+
self.relevancyStrictness = searchParamsObject.relevancyStrictness
57+
self.attributesToHighlight = searchParamsObject.attributesToHighlight
58+
self.attributesToSnippet = searchParamsObject.attributesToSnippet
59+
self.highlightPreTag = searchParamsObject.highlightPreTag
60+
self.highlightPostTag = searchParamsObject.highlightPostTag
61+
self.snippetEllipsisText = searchParamsObject.snippetEllipsisText
62+
self.restrictHighlightAndSnippetArrays = searchParamsObject.restrictHighlightAndSnippetArrays
63+
self.hitsPerPage = searchParamsObject.hitsPerPage
64+
self.minWordSizefor1Typo = searchParamsObject.minWordSizefor1Typo
65+
self.minWordSizefor2Typos = searchParamsObject.minWordSizefor2Typos
66+
self.typoTolerance = searchParamsObject.typoTolerance
67+
self.allowTyposOnNumericTokens = searchParamsObject.allowTyposOnNumericTokens
68+
self.disableTypoToleranceOnAttributes = searchParamsObject.disableTypoToleranceOnAttributes
69+
self.ignorePlurals = searchParamsObject.ignorePlurals
70+
self.removeStopWords = searchParamsObject.removeStopWords
71+
self.keepDiacriticsOnCharacters = searchParamsObject.keepDiacriticsOnCharacters
72+
self.queryLanguages = searchParamsObject.queryLanguages
73+
self.decompoundQuery = searchParamsObject.decompoundQuery
74+
self.enableRules = searchParamsObject.enableRules
75+
self.enablePersonalization = searchParamsObject.enablePersonalization
76+
self.queryType = searchParamsObject.queryType
77+
self.removeWordsIfNoResults = searchParamsObject.removeWordsIfNoResults
78+
self.mode = searchParamsObject.mode
79+
self.semanticSearch = searchParamsObject.semanticSearch
80+
self.advancedSyntax = searchParamsObject.advancedSyntax
81+
self.optionalWords = searchParamsObject.optionalWords
82+
self.disableExactOnAttributes = searchParamsObject.disableExactOnAttributes
83+
self.exactOnSingleWordQuery = searchParamsObject.exactOnSingleWordQuery
84+
self.alternativesAsExact = searchParamsObject.alternativesAsExact
85+
self.advancedSyntaxFeatures = searchParamsObject.advancedSyntaxFeatures
86+
self.distinct = searchParamsObject.distinct
87+
self.replaceSynonymsInHighlight = searchParamsObject.replaceSynonymsInHighlight
88+
self.minProximity = searchParamsObject.minProximity
89+
self.responseFields = searchParamsObject.responseFields
90+
self.maxFacetHits = searchParamsObject.maxFacetHits
91+
self.maxValuesPerFacet = searchParamsObject.maxValuesPerFacet
92+
self.sortFacetValuesBy = searchParamsObject.sortFacetValuesBy
93+
self.attributeCriteriaComputedByMinProximity = searchParamsObject.attributeCriteriaComputedByMinProximity
94+
self.renderingContent = searchParamsObject.renderingContent
95+
self.enableReRanking = searchParamsObject.enableReRanking
96+
self.reRankingApplyFilter = searchParamsObject.reRankingApplyFilter
97+
self.indexName = indexName
98+
self.type = .default
99+
}
100+
101+
init(from searchParamsObject: SearchSearchParamsObject, options: SearchForHitsOptions, params: String? = nil) {
102+
self = .init(from: searchParamsObject, indexName: options.indexName, params: params)
103+
}
104+
}
105+
106+
public extension SearchForFacets {
107+
init(from searchParamsObject: SearchSearchParamsObject, options: SearchForFacetsOptions, params: String? = nil) {
108+
self.params = params
109+
self.query = searchParamsObject.query
110+
self.similarQuery = searchParamsObject.similarQuery
111+
self.filters = searchParamsObject.filters
112+
self.facetFilters = searchParamsObject.facetFilters
113+
self.optionalFilters = searchParamsObject.optionalFilters
114+
self.numericFilters = searchParamsObject.numericFilters
115+
self.tagFilters = searchParamsObject.tagFilters
116+
self.sumOrFiltersScores = searchParamsObject.sumOrFiltersScores
117+
self.restrictSearchableAttributes = searchParamsObject.restrictSearchableAttributes
118+
self.facets = searchParamsObject.facets
119+
self.facetingAfterDistinct = searchParamsObject.facetingAfterDistinct
120+
self.page = searchParamsObject.page
121+
self.offset = searchParamsObject.offset
122+
self.length = searchParamsObject.length
123+
self.aroundLatLng = searchParamsObject.aroundLatLng
124+
self.aroundLatLngViaIP = searchParamsObject.aroundLatLngViaIP
125+
self.aroundRadius = searchParamsObject.aroundRadius
126+
self.aroundPrecision = searchParamsObject.aroundPrecision
127+
self.minimumAroundRadius = searchParamsObject.minimumAroundRadius
128+
self.insideBoundingBox = searchParamsObject.insideBoundingBox
129+
self.insidePolygon = searchParamsObject.insidePolygon
130+
self.naturalLanguages = searchParamsObject.naturalLanguages
131+
self.ruleContexts = searchParamsObject.ruleContexts
132+
self.personalizationImpact = searchParamsObject.personalizationImpact
133+
self.userToken = searchParamsObject.userToken
134+
self.getRankingInfo = searchParamsObject.getRankingInfo
135+
self.synonyms = searchParamsObject.synonyms
136+
self.clickAnalytics = searchParamsObject.clickAnalytics
137+
self.analytics = searchParamsObject.analytics
138+
self.analyticsTags = searchParamsObject.analyticsTags
139+
self.percentileComputation = searchParamsObject.percentileComputation
140+
self.enableABTest = searchParamsObject.enableABTest
141+
self.attributesToRetrieve = searchParamsObject.attributesToRetrieve
142+
self.ranking = searchParamsObject.ranking
143+
self.customRanking = searchParamsObject.customRanking
144+
self.relevancyStrictness = searchParamsObject.relevancyStrictness
145+
self.attributesToHighlight = searchParamsObject.attributesToHighlight
146+
self.attributesToSnippet = searchParamsObject.attributesToSnippet
147+
self.highlightPreTag = searchParamsObject.highlightPreTag
148+
self.highlightPostTag = searchParamsObject.highlightPostTag
149+
self.snippetEllipsisText = searchParamsObject.snippetEllipsisText
150+
self.restrictHighlightAndSnippetArrays = searchParamsObject.restrictHighlightAndSnippetArrays
151+
self.hitsPerPage = searchParamsObject.hitsPerPage
152+
self.minWordSizefor1Typo = searchParamsObject.minWordSizefor1Typo
153+
self.minWordSizefor2Typos = searchParamsObject.minWordSizefor2Typos
154+
self.typoTolerance = searchParamsObject.typoTolerance
155+
self.allowTyposOnNumericTokens = searchParamsObject.allowTyposOnNumericTokens
156+
self.disableTypoToleranceOnAttributes = searchParamsObject.disableTypoToleranceOnAttributes
157+
self.ignorePlurals = searchParamsObject.ignorePlurals
158+
self.removeStopWords = searchParamsObject.removeStopWords
159+
self.keepDiacriticsOnCharacters = searchParamsObject.keepDiacriticsOnCharacters
160+
self.queryLanguages = searchParamsObject.queryLanguages
161+
self.decompoundQuery = searchParamsObject.decompoundQuery
162+
self.enableRules = searchParamsObject.enableRules
163+
self.enablePersonalization = searchParamsObject.enablePersonalization
164+
self.queryType = searchParamsObject.queryType
165+
self.removeWordsIfNoResults = searchParamsObject.removeWordsIfNoResults
166+
self.mode = searchParamsObject.mode
167+
self.semanticSearch = searchParamsObject.semanticSearch
168+
self.advancedSyntax = searchParamsObject.advancedSyntax
169+
self.optionalWords = searchParamsObject.optionalWords
170+
self.disableExactOnAttributes = searchParamsObject.disableExactOnAttributes
171+
self.exactOnSingleWordQuery = searchParamsObject.exactOnSingleWordQuery
172+
self.alternativesAsExact = searchParamsObject.alternativesAsExact
173+
self.advancedSyntaxFeatures = searchParamsObject.advancedSyntaxFeatures
174+
self.distinct = searchParamsObject.distinct
175+
self.replaceSynonymsInHighlight = searchParamsObject.replaceSynonymsInHighlight
176+
self.minProximity = searchParamsObject.minProximity
177+
self.responseFields = searchParamsObject.responseFields
178+
self.maxFacetHits = searchParamsObject.maxFacetHits
179+
self.maxValuesPerFacet = searchParamsObject.maxValuesPerFacet
180+
self.sortFacetValuesBy = searchParamsObject.sortFacetValuesBy
181+
self.attributeCriteriaComputedByMinProximity = searchParamsObject.attributeCriteriaComputedByMinProximity
182+
self.renderingContent = searchParamsObject.renderingContent
183+
self.enableReRanking = searchParamsObject.enableReRanking
184+
self.reRankingApplyFilter = searchParamsObject.reRankingApplyFilter
185+
self.facet = options.facet
186+
self.indexName = options.indexName
187+
self.facetQuery = options.facetQuery
188+
self.maxFacetHits = options.maxFacetHits
189+
self.type = .facet
190+
}
191+
}

0 commit comments

Comments
 (0)