Skip to content

Commit b2a82ba

Browse files
feat(search): Disjunctive Faceting (#802)
1 parent 6ab9735 commit b2a82ba

File tree

3 files changed

+581
-1
lines changed

3 files changed

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

Sources/AlgoliaSearchClient/Index/Index+Search.swift

Lines changed: 58 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -70,7 +70,7 @@ public extension Index {
7070
- Parameter strategy: The MultipleQueriesStrategy of the query.
7171
- Parameter requestOptions: Configure request locally with RequestOptions.
7272
- Parameter completion: Result completion
73-
- Returns: SearchesResponse object
73+
- Returns: Launched asynchronous operation
7474
*/
7575
@discardableResult func search(queries: [Query],
7676
strategy: MultipleQueriesStrategy = .none,
@@ -79,6 +79,63 @@ public extension Index {
7979
return try transport.execute(command)
8080
}
8181

82+
// MARK: - Disjunctive Faceting
83+
84+
/**
85+
Method used for perform search with disjunctive facets.
86+
87+
- Parameter query: The Query used to search.
88+
- Parameter refinements: Refinements to apply to the search in form of dictionary with
89+
facet attribute as a key and a list of facet values for the designated attribute
90+
- Parameter disjunctiveFacets: Set of facets attributes applied disjunctively (with OR operator)
91+
- Parameter keepSelectedEmptyFacets: Whether the selected facet values might be preserved even
92+
in case of their absence in the search response
93+
- Returns: SearchesResponse object
94+
*/
95+
func searchDisjunctiveFaceting(query: Query,
96+
refinements: [Attribute: [String]],
97+
disjunctiveFacets: Set<Attribute>,
98+
keepSelectedEmptyFacets: Bool = true) throws -> SearchResponse {
99+
let helper = DisjunctiveFacetingHelper(query: query,
100+
refinements: refinements,
101+
disjunctiveFacets: disjunctiveFacets)
102+
let queries = helper.makeQueries()
103+
let response = try search(queries: queries)
104+
return try helper.mergeResponses(response.results,
105+
keepSelectedEmptyFacets: keepSelectedEmptyFacets)
106+
}
107+
108+
/**
109+
Method used for perform search with disjunctive facets.
110+
111+
- Parameter query: The Query used to search.
112+
- Parameter refinements: Refinements to apply to the search in form of dictionary with
113+
facet attribute as a key and a list of facet values for the designated attribute
114+
- Parameter disjunctiveFacets: Set of facets attributes applied disjunctively (with OR operator)
115+
- Parameter keepSelectedEmptyFacets: Whether the selected facet values might be preserved even
116+
in case of their absence in the search response
117+
- Parameter completion: Result completion
118+
- Returns: Launched asynchronous operation
119+
*/
120+
func searchDisjunctiveFaceting(query: Query,
121+
refinements: [Attribute: [String]],
122+
disjunctiveFacets: Set<Attribute>,
123+
keepSelectedEmptyFacets: Bool = true,
124+
completion: @escaping ResultCallback<SearchResponse>) -> Operation & TransportTask {
125+
let helper = DisjunctiveFacetingHelper(query: query,
126+
refinements: refinements,
127+
disjunctiveFacets: disjunctiveFacets)
128+
let queries = helper.makeQueries()
129+
return search(queries: queries) { result in
130+
completion(result.flatMap { response in
131+
Result {
132+
try helper.mergeResponses(response.results,
133+
keepSelectedEmptyFacets: keepSelectedEmptyFacets)
134+
}
135+
})
136+
}
137+
}
138+
82139
// MARK: - Browse
83140

84141
/**

0 commit comments

Comments
 (0)