Skip to content

Commit 92aeb27

Browse files
authored
feat(swift): add disjunctive faceting (#3778)
1 parent 6e91dbf commit 92aeb27

File tree

5 files changed

+934
-0
lines changed

5 files changed

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

clients/algoliasearch-client-swift/Sources/Search/Extra/SearchClientExtension.swift

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

638638
return true
639639
}
640+
641+
/**
642+
Method used for perform search with disjunctive facets.
643+
644+
- Parameter indexName: The name of the index in which the search queries should be performed
645+
- Parameter searchParamsObject: The search query params.
646+
- Parameter refinements: Refinements to apply to the search in form of dictionary with
647+
facet attribute as a key and a list of facet values for the designated attribute.
648+
Any facet in this list not present in the `disjunctiveFacets` set will be filtered conjunctively.
649+
- Parameter disjunctiveFacets: Set of facets attributes applied disjunctively (with OR operator)
650+
- Parameter keepSelectedEmptyFacets: Whether the selected facet values might be preserved even
651+
in case of their absence in the search response
652+
- Parameter requestOptions: Configure request locally with RequestOptions.
653+
- Returns: SearchDisjunctiveFacetingResponse<T> - a struct containing the merge response from all the disjunctive faceting search queries,
654+
and a list of disjunctive facets
655+
*/
656+
func searchDisjunctiveFaceting<T: Codable>(
657+
indexName: String,
658+
searchParamsObject: SearchSearchParamsObject,
659+
refinements: [String: [String]],
660+
disjunctiveFacets: Set<String>,
661+
keepSelectedEmptyFacets: Bool = true,
662+
requestOptions: RequestOptions? = nil
663+
) async throws -> SearchDisjunctiveFacetingResponse<T> {
664+
let helper = DisjunctiveFacetingHelper(
665+
query: SearchForHits(from: searchParamsObject, indexName: indexName),
666+
refinements: refinements,
667+
disjunctiveFacets: disjunctiveFacets
668+
)
669+
let queries = helper.makeQueries()
670+
let responses: [SearchResponse<T>] = try await self.searchForHitsWithResponse(
671+
searchMethodParams: SearchMethodParams(requests: queries),
672+
requestOptions: requestOptions
673+
)
674+
return try helper.mergeResponses(responses, keepSelectedEmptyFacets: keepSelectedEmptyFacets)
675+
}
640676
}
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+
extension SearchQuery {
9+
public init(from searchParamsObject: SearchSearchParamsObject, options: SearchForHitsOptions, params: String? = nil) {
10+
self = .searchForHits(SearchForHits(from: searchParamsObject, indexName: options.indexName, params: params))
11+
}
12+
13+
public init(from searchParamsObject: SearchSearchParamsObject, options: SearchForFacetsOptions, params: String? = nil) {
14+
self = .searchForFacets(SearchForFacets(from: searchParamsObject, options: options, params: params))
15+
}
16+
}
17+
18+
extension SearchForHits {
19+
public 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+
public init(from searchParamsObject: SearchSearchParamsObject, options: SearchForHitsOptions, params: String? = nil) {
102+
self = .init(from: searchParamsObject, indexName: options.indexName, params: params)
103+
}
104+
}
105+
106+
extension SearchForFacets {
107+
public 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)