Skip to content

Commit

Permalink
feat(swift): add disjunctive faceting (generated)
Browse files Browse the repository at this point in the history
algolia/api-clients-automation#3778

Co-authored-by: algolia-bot <[email protected]>
Co-authored-by: Thomas Raffray <[email protected]>
  • Loading branch information
algolia-bot and Fluf22 committed Sep 19, 2024
1 parent 65a555a commit 9a0ec59
Show file tree
Hide file tree
Showing 3 changed files with 376 additions and 0 deletions.
150 changes: 150 additions & 0 deletions Sources/Search/Extra/DisjunctiveFaceting.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
//
// DisjunctiveFaceting.swift
// AlgoliaSearchClient
//
// Created by Algolia on 18/09/2024.
//

import Foundation

public struct SearchDisjunctiveFacetingResponse<T: Codable> {
let response: SearchResponse<T>
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<String>

/// 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<String> {
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<T: Codable>(
_ responses: [SearchResponse<T>],
keepSelectedEmptyFacets _: Bool = true
) throws -> SearchDisjunctiveFacetingResponse<T> {
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."
}
}
}
35 changes: 35 additions & 0 deletions Sources/Search/Extra/SearchClientExtension.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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<T> - a struct containing the merge response from all the
/// disjunctive faceting search queries,
/// and a list of disjunctive facets
func searchDisjunctiveFaceting<T: Codable>(
indexName: String,
searchParamsObject: SearchSearchParamsObject,
refinements: [String: [String]],
disjunctiveFacets: Set<String>,
keepSelectedEmptyFacets: Bool = true,
requestOptions: RequestOptions? = nil
) async throws -> SearchDisjunctiveFacetingResponse<T> {
let helper = DisjunctiveFacetingHelper(
query: SearchForHits(from: searchParamsObject, indexName: indexName),
refinements: refinements,
disjunctiveFacets: disjunctiveFacets
)
let queries = helper.makeQueries()
let responses: [SearchResponse<T>] = try await self.searchForHitsWithResponse(
searchMethodParams: SearchMethodParams(requests: queries),
requestOptions: requestOptions
)
return try helper.mergeResponses(responses, keepSelectedEmptyFacets: keepSelectedEmptyFacets)
}
}
191 changes: 191 additions & 0 deletions Sources/Search/Extra/SearchQueryExtension.swift
Original file line number Diff line number Diff line change
@@ -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
}
}

0 comments on commit 9a0ec59

Please sign in to comment.