Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
//
// 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(
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 = 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 = query
mainQuery.filters = [
mainQuery.filters,
buildFilters(excluding: .none)
]
.compactMap { $0 }
.filter { !$0.isEmpty }
.joined(separator: " AND ")

queries.append(.searchForHits(mainQuery))

disjunctiveFacets
.sorted(by: { $0 < $1 })
.forEach { disjunctiveFacet in
var disjunctiveQuery = query
disjunctiveQuery.facets = [disjunctiveFacet]
disjunctiveQuery.filters = [
disjunctiveQuery.filters,
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 disjunctiveFacets.contains(attribute) else {
return []
}
return 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 = 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:
return "Unexpected empty search responses list. At least one search responses might be present."
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

wouldn't this be caught by the transporter? I'm not sure it's possible to have 0 responses

}
}

}
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
- 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)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,191 @@
//
// SearchQueryExtension.swift
// AlgoliaSearchClient
//
// Created by Algolia on 18/09/2024.
//

extension SearchQuery {
public init(from searchParamsObject: SearchSearchParamsObject, options: SearchForHitsOptions, params: String? = nil) {
self = .searchForHits(SearchForHits(from: searchParamsObject, indexName: options.indexName, params: params))
}

public init(from searchParamsObject: SearchSearchParamsObject, options: SearchForFacetsOptions, params: String? = nil) {
self = .searchForFacets(SearchForFacets(from: searchParamsObject, options: options, params: params))
}
}

extension SearchForHits {
public 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
}

public init(from searchParamsObject: SearchSearchParamsObject, options: SearchForHitsOptions, params: String? = nil) {
self = .init(from: searchParamsObject, indexName: options.indexName, params: params)
}
}

extension SearchForFacets {
public 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
}
}
Loading
Loading