diff --git a/examples/android/src/main/AndroidManifest.xml b/examples/android/src/main/AndroidManifest.xml index f786b5f32..a1d081133 100644 --- a/examples/android/src/main/AndroidManifest.xml +++ b/examples/android/src/main/AndroidManifest.xml @@ -212,6 +212,12 @@ android:name=".showcase.compose.agent.AgentStudioShowcase" android:parentActivityName=".directory.DirectoryActivity" /> + + + + >) = diff --git a/examples/android/src/main/kotlin/com/algolia/instantsearch/examples/android/showcase/compose/composition/CompositionShowcase.kt b/examples/android/src/main/kotlin/com/algolia/instantsearch/examples/android/showcase/compose/composition/CompositionShowcase.kt new file mode 100644 index 000000000..5de8b90ff --- /dev/null +++ b/examples/android/src/main/kotlin/com/algolia/instantsearch/examples/android/showcase/compose/composition/CompositionShowcase.kt @@ -0,0 +1,237 @@ +package com.algolia.instantsearch.examples.android.showcase.compose.composition + +import android.os.Bundle +import androidx.activity.compose.setContent +import androidx.appcompat.app.AppCompatActivity +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material.Button +import androidx.compose.material.Divider +import androidx.compose.material.MaterialTheme +import androidx.compose.material.OutlinedTextField +import androidx.compose.material.Scaffold +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.algolia.client.model.search.Hit +import com.algolia.instantsearch.compose.hits.HitsState +import com.algolia.instantsearch.compose.item.StatsTextState +import com.algolia.instantsearch.compose.searchbox.SearchBoxState +import com.algolia.instantsearch.core.connection.ConnectionHandler +import com.algolia.instantsearch.core.hits.connectHitsView +import com.algolia.instantsearch.examples.android.showcase.compose.ui.ShowcaseTheme +import com.algolia.instantsearch.examples.android.showcase.compose.ui.component.SearchTopBar +import com.algolia.instantsearch.examples.android.showcase.compose.ui.component.TitleTopBar +import com.algolia.instantsearch.searchbox.SearchBoxConnector +import com.algolia.instantsearch.searchbox.connectView +import com.algolia.instantsearch.searcher.composition.CompositionSearcher +import com.algolia.instantsearch.stats.DefaultStatsPresenter +import com.algolia.instantsearch.stats.StatsConnector +import com.algolia.instantsearch.stats.connectView +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.contentOrNull + +/** + * Showcase for [CompositionSearcher], the searcher targeting the Algolia + * Composition API (`/1/compositions/{compositionID}/run` endpoint). + * + * A composition is configured per application in the Algolia dashboard, so + * there is no public demo composition to hardcode here. The screen starts with + * a small form asking for your application credentials and composition ID, + * then drives the regular InstantSearch components (search box, stats, hits) + * through a [CompositionSearcher]. + */ +class CompositionShowcase : AppCompatActivity() { + + private var session by mutableStateOf(null) + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContent { + ShowcaseTheme { + when (val current = session) { + null -> CredentialsForm { applicationID, apiKey, compositionID -> + session = CompositionSession(applicationID, apiKey, compositionID) + } + else -> CompositionSearchScreen(current) + } + } + } + } + + override fun onDestroy() { + super.onDestroy() + session?.close() + } +} + +/** + * Holds the searcher and its connections, created once the credentials are + * submitted. + */ +private class CompositionSession( + applicationID: String, + apiKey: String, + compositionID: String, +) { + val searchBoxState = SearchBoxState() + val statsText = StatsTextState() + val hitsState = HitsState() + var error by mutableStateOf(null) + + private val searcher = CompositionSearcher( + applicationID = applicationID, + apiKey = apiKey, + compositionID = compositionID, + ) + private val searchBox = SearchBoxConnector(searcher) + private val stats = StatsConnector(searcher) + private val connections = ConnectionHandler(searchBox, stats) + + init { + connections += searchBox.connectView(searchBoxState) + connections += stats.connectView(statsText, DefaultStatsPresenter()) + connections += searcher.connectHitsView(hitsState) { response -> + response.hits.map { it.toCompositionHit() } + } + searcher.error.subscribe { error = it?.message } + searcher.searchAsync() + } + + fun close() { + searcher.error.unsubscribeAll() + searcher.cancel() + connections.clear() + } +} + +/** + * Composition records don't have a fixed schema, so the row displays the most + * common "title" attributes and falls back to the objectID. + */ +private data class CompositionHit( + val objectID: String, + val title: String, +) + +private fun Hit.toCompositionHit(): CompositionHit { + val title = listOf("name", "title", "label") + .firstNotNullOfOrNull { attribute -> + (additionalProperties?.get(attribute) as? JsonPrimitive)?.contentOrNull + } + return CompositionHit(objectID = objectID, title = title ?: objectID) +} + +@Composable +private fun CredentialsForm(onSubmit: (String, String, String) -> Unit) { + var applicationID by remember { mutableStateOf("") } + var apiKey by remember { mutableStateOf("") } + var compositionID by remember { mutableStateOf("") } + + Scaffold( + topBar = { TitleTopBar(title = "Composition (experimental)") }, + ) { padding -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(padding) + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Text( + text = "Compositions are configured per application in the Algolia dashboard. " + + "Enter the credentials of an application with at least one composition.", + style = MaterialTheme.typography.body2, + ) + OutlinedTextField( + value = applicationID, + onValueChange = { applicationID = it }, + modifier = Modifier.fillMaxWidth(), + label = { Text("Application ID") }, + singleLine = true, + ) + OutlinedTextField( + value = apiKey, + onValueChange = { apiKey = it }, + modifier = Modifier.fillMaxWidth(), + label = { Text("Search-only API Key") }, + singleLine = true, + ) + OutlinedTextField( + value = compositionID, + onValueChange = { compositionID = it }, + modifier = Modifier.fillMaxWidth(), + label = { Text("Composition ID") }, + singleLine = true, + ) + Button( + enabled = applicationID.isNotBlank() && apiKey.isNotBlank() && compositionID.isNotBlank(), + onClick = { onSubmit(applicationID.trim(), apiKey.trim(), compositionID.trim()) }, + ) { + Text("Start searching") + } + } + } +} + +@Composable +private fun CompositionSearchScreen(session: CompositionSession) { + val listState = rememberLazyListState() + Scaffold( + topBar = { + SearchTopBar( + placeHolderText = "Search in your composition", + searchBoxState = session.searchBoxState, + lazyListState = listState, + ) + }, + ) { padding -> + Column( + Modifier + .fillMaxSize() + .padding(padding), + ) { + Text( + text = session.statsText.stats, + style = MaterialTheme.typography.caption, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp), + ) + session.error?.let { + Text( + text = it, + color = MaterialTheme.colors.error, + style = MaterialTheme.typography.caption, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp), + ) + } + LazyColumn(state = listState) { + items(session.hitsState.hits) { hit -> + Column( + Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 8.dp), + ) { + Text(text = hit.title, style = MaterialTheme.typography.body1) + Text( + text = hit.objectID, + style = MaterialTheme.typography.caption, + color = MaterialTheme.colors.onSurface.copy(alpha = 0.6f), + ) + } + Divider() + } + } + } + } +} diff --git a/instantsearch/src/commonMain/kotlin/com/algolia/instantsearch/searcher/composition/CompositionFacetsSearcher.kt b/instantsearch/src/commonMain/kotlin/com/algolia/instantsearch/searcher/composition/CompositionFacetsSearcher.kt new file mode 100644 index 000000000..8d486225b --- /dev/null +++ b/instantsearch/src/commonMain/kotlin/com/algolia/instantsearch/searcher/composition/CompositionFacetsSearcher.kt @@ -0,0 +1,114 @@ +package com.algolia.instantsearch.searcher.composition + +import com.algolia.client.api.CompositionClient +import com.algolia.client.model.search.SearchParamsObject +import com.algolia.client.transport.RequestOptions +import com.algolia.instantsearch.searcher.SearcherForFacets +import com.algolia.instantsearch.searcher.SearcherScope +import com.algolia.instantsearch.searcher.composition.internal.DefaultCompositionFacetsSearchService +import com.algolia.instantsearch.searcher.composition.internal.DefaultCompositionFacetsSearcher +import com.algolia.instantsearch.searcher.facets.SearchForFacetQuery +import com.algolia.instantsearch.searcher.internal.defaultDispatcher +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope + +/** + * The component handling search requests and managing the search sessions. + * This implementation searches for facet values using the Algolia Composition API + * (`/1/compositions/{compositionID}/facets/{facetName}/query` endpoint). + */ +public interface CompositionFacetsSearcher : SearcherForFacets { + + /** + * Unique Composition ObjectID. + */ + public var compositionID: String + + /** + * Facets query. + */ + public var facetQuery: String? + + /** + * Maximum number of facet values to return. + */ + public var maxFacetHits: Int? +} + +/** + * Creates an instance of [CompositionFacetsSearcher]. + * + * @param client composition client instance + * @param compositionID unique Composition ObjectID + * @param attribute facet attribute + * @param query the query used to narrow down the facet values search + * @param facetQuery the facet query used to search for facets + * @param maxFacetHits maximum number of facet values to return + * @param requestOptions request local configuration + * @param coroutineScope scope of coroutine operations + * @param coroutineDispatcher async search dispatcher + * @param triggerSearchFor request condition + */ +public fun CompositionFacetsSearcher( + client: CompositionClient, + compositionID: String, + attribute: String, + query: SearchParamsObject = SearchParamsObject(), + facetQuery: String? = null, + maxFacetHits: Int? = null, + requestOptions: RequestOptions? = null, + coroutineScope: CoroutineScope = SearcherScope(), + coroutineDispatcher: CoroutineDispatcher = defaultDispatcher, + triggerSearchFor: SearchForFacetQuery = SearchForFacetQuery.All, +): CompositionFacetsSearcher = DefaultCompositionFacetsSearcher( + searchService = DefaultCompositionFacetsSearchService(client), + compositionID = compositionID, + query = query, + attribute = attribute, + facetQuery = facetQuery, + maxFacetHits = maxFacetHits, + requestOptions = requestOptions, + coroutineScope = coroutineScope, + coroutineDispatcher = coroutineDispatcher, + triggerSearchFor = triggerSearchFor, +) + +/** + * Creates an instance of [CompositionFacetsSearcher]. + * + * @param applicationID application ID + * @param apiKey API Key + * @param compositionID unique Composition ObjectID + * @param attribute facet attribute + * @param query the query used to narrow down the facet values search + * @param facetQuery the facet query used to search for facets + * @param maxFacetHits maximum number of facet values to return + * @param requestOptions request local configuration + * @param coroutineScope scope of coroutine operations + * @param coroutineDispatcher async search dispatcher + * @param triggerSearchFor request condition + */ +public fun CompositionFacetsSearcher( + applicationID: String, + apiKey: String, + compositionID: String, + attribute: String, + query: SearchParamsObject = SearchParamsObject(), + facetQuery: String? = null, + maxFacetHits: Int? = null, + requestOptions: RequestOptions? = null, + coroutineScope: CoroutineScope = SearcherScope(), + coroutineDispatcher: CoroutineDispatcher = defaultDispatcher, + triggerSearchFor: SearchForFacetQuery = SearchForFacetQuery.All, +): CompositionFacetsSearcher = CompositionFacetsSearcher( + client = CompositionClient(applicationID, apiKey), + compositionID = compositionID, + attribute = attribute, + query = query, + facetQuery = facetQuery, + maxFacetHits = maxFacetHits, + requestOptions = requestOptions, + coroutineScope = coroutineScope, + coroutineDispatcher = coroutineDispatcher, + triggerSearchFor = triggerSearchFor, +) diff --git a/instantsearch/src/commonMain/kotlin/com/algolia/instantsearch/searcher/composition/CompositionSearcher.kt b/instantsearch/src/commonMain/kotlin/com/algolia/instantsearch/searcher/composition/CompositionSearcher.kt new file mode 100644 index 000000000..d6e7047d3 --- /dev/null +++ b/instantsearch/src/commonMain/kotlin/com/algolia/instantsearch/searcher/composition/CompositionSearcher.kt @@ -0,0 +1,96 @@ +package com.algolia.instantsearch.searcher.composition + +import com.algolia.client.api.CompositionClient +import com.algolia.client.model.search.SearchParamsObject +import com.algolia.client.transport.RequestOptions +import com.algolia.instantsearch.searcher.FilterGroupsHolder +import com.algolia.instantsearch.searcher.SearcherForHits +import com.algolia.instantsearch.searcher.SearcherScope +import com.algolia.instantsearch.searcher.composition.internal.DefaultCompositionSearchService +import com.algolia.instantsearch.searcher.composition.internal.DefaultCompositionSearcher +import com.algolia.instantsearch.searcher.hits.SearchForQuery +import com.algolia.instantsearch.searcher.internal.defaultDispatcher +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope + +/** + * The component handling search requests and managing the search sessions. + * This implementation runs an Algolia Composition (`/1/compositions/{compositionID}/run` endpoint). + * + * The composition run response is presented as a regular [com.algolia.client.model.search.SearchResponse], + * so a [CompositionSearcher] can be connected to the same components as a + * [com.algolia.instantsearch.searcher.hits.HitsSearcher] (hits, stats, facet list, search box, + * filter state, paging, ...). + * + * Unlike [com.algolia.instantsearch.searcher.hits.HitsSearcher], disjunctive faceting is handled + * server-side by the Composition API: the refined disjunctive attributes are annotated with the + * `disjunctive` modifier in the requested facets instead of performing a multi-query fan-out. + * A [CompositionSearcher] cannot join a [com.algolia.instantsearch.searcher.multi.MultiSearcher]. + */ +public interface CompositionSearcher : SearcherForHits, FilterGroupsHolder { + + /** + * Unique Composition ObjectID. + */ + public var compositionID: String +} + +/** + * Creates an instance of [CompositionSearcher]. + * + * @param client composition client instance + * @param compositionID unique Composition ObjectID + * @param query the query used for search + * @param requestOptions request local configuration + * @param coroutineScope scope of coroutine operations + * @param coroutineDispatcher async search dispatcher + * @param triggerSearchFor request condition + */ +public fun CompositionSearcher( + client: CompositionClient, + compositionID: String, + query: SearchParamsObject = SearchParamsObject(), + requestOptions: RequestOptions? = null, + coroutineScope: CoroutineScope = SearcherScope(), + coroutineDispatcher: CoroutineDispatcher = defaultDispatcher, + triggerSearchFor: SearchForQuery = SearchForQuery.All, +): CompositionSearcher = DefaultCompositionSearcher( + searchService = DefaultCompositionSearchService(client), + compositionID = compositionID, + query = query, + requestOptions = requestOptions, + coroutineScope = coroutineScope, + coroutineDispatcher = coroutineDispatcher, + triggerSearchFor = triggerSearchFor, +) + +/** + * Creates an instance of [CompositionSearcher]. + * + * @param applicationID application ID + * @param apiKey API Key + * @param compositionID unique Composition ObjectID + * @param query the query used for search + * @param requestOptions request local configuration + * @param coroutineScope scope of coroutine operations + * @param coroutineDispatcher async search dispatcher + * @param triggerSearchFor request condition + */ +public fun CompositionSearcher( + applicationID: String, + apiKey: String, + compositionID: String, + query: SearchParamsObject = SearchParamsObject(), + requestOptions: RequestOptions? = null, + coroutineScope: CoroutineScope = SearcherScope(), + coroutineDispatcher: CoroutineDispatcher = defaultDispatcher, + triggerSearchFor: SearchForQuery = SearchForQuery.All, +): CompositionSearcher = CompositionSearcher( + client = CompositionClient(applicationID, apiKey), + compositionID = compositionID, + query = query, + requestOptions = requestOptions, + coroutineScope = coroutineScope, + coroutineDispatcher = coroutineDispatcher, + triggerSearchFor = triggerSearchFor, +) diff --git a/instantsearch/src/commonMain/kotlin/com/algolia/instantsearch/searcher/composition/internal/CompositionExtensions.kt b/instantsearch/src/commonMain/kotlin/com/algolia/instantsearch/searcher/composition/internal/CompositionExtensions.kt new file mode 100644 index 000000000..70497f589 --- /dev/null +++ b/instantsearch/src/commonMain/kotlin/com/algolia/instantsearch/searcher/composition/internal/CompositionExtensions.kt @@ -0,0 +1,62 @@ +package com.algolia.instantsearch.searcher.composition.internal + +import com.algolia.client.model.composition.Params +import com.algolia.client.model.composition.SearchResultsItem +import com.algolia.client.model.search.SearchParamsObject +import com.algolia.client.model.search.SearchResponse +import kotlinx.serialization.json.Json +import com.algolia.client.model.search.SearchForFacetValuesResponse +import com.algolia.client.model.composition.SearchForFacetValuesResults as CompositionSearchForFacetValuesResults +import com.algolia.client.model.search.FacetHits as SearchFacetHits + +/** + * Lenient json used to convert between structurally identical models generated for different API + * client namespaces: the parameters or response fields not supported by the destination model are + * dropped. + */ +private val compositionJson = Json { + ignoreUnknownKeys = true + encodeDefaults = false +} + +/** + * Converts search parameters to composition run parameters. + * The parameters not supported by the Composition API are dropped. + */ +internal fun SearchParamsObject.toCompositionParams(): Params { + val element = compositionJson.encodeToJsonElement(SearchParamsObject.serializer(), this) + return compositionJson.decodeFromJsonElement(Params.serializer(), element) +} + +/** + * Annotates the requested facets with the `disjunctive` modifier for the given attributes. + */ +internal fun Params.annotateDisjunctiveFacets(disjunctiveAttributes: Set): Params { + val facets = facets + if (disjunctiveAttributes.isEmpty() || facets.isNullOrEmpty()) return this + return copy( + facets = facets.map { attribute -> + if (attribute in disjunctiveAttributes) "disjunctive($attribute)" else attribute + } + ) +} + +/** + * Converts a composition run result to a regular [SearchResponse], allowing the reuse of all the + * components consuming search responses. + */ +internal fun SearchResultsItem.toSearchResponse(): SearchResponse { + val element = compositionJson.encodeToJsonElement(SearchResultsItem.serializer(), this) + return compositionJson.decodeFromJsonElement(SearchResponse.serializer(), element) +} + +/** + * Converts a composition facet values search result to a regular [SearchForFacetValuesResponse]. + */ +internal fun CompositionSearchForFacetValuesResults.toSearchForFacetValuesResponse(): SearchForFacetValuesResponse { + return SearchForFacetValuesResponse( + facetHits = facetHits.map { SearchFacetHits(value = it.value, highlighted = it.highlighted, count = it.count) }, + exhaustiveFacetsCount = exhaustiveFacetsCount, + processingTimeMS = processingTimeMS, + ) +} diff --git a/instantsearch/src/commonMain/kotlin/com/algolia/instantsearch/searcher/composition/internal/CompositionFacetsSearchService.kt b/instantsearch/src/commonMain/kotlin/com/algolia/instantsearch/searcher/composition/internal/CompositionFacetsSearchService.kt new file mode 100644 index 000000000..f505f6538 --- /dev/null +++ b/instantsearch/src/commonMain/kotlin/com/algolia/instantsearch/searcher/composition/internal/CompositionFacetsSearchService.kt @@ -0,0 +1,61 @@ +package com.algolia.instantsearch.searcher.composition.internal + +import com.algolia.client.api.CompositionClient +import com.algolia.client.model.search.SearchForFacetValuesResponse +import com.algolia.client.model.search.SearchParamsObject +import com.algolia.client.transport.RequestOptions +import com.algolia.instantsearch.searcher.multi.internal.SearchService +import com.algolia.client.model.composition.SearchForFacetValuesParams as CompositionSearchForFacetValuesParams +import com.algolia.client.model.composition.SearchForFacetValuesRequest as CompositionSearchForFacetValuesRequest + +/** + * Search service for facet values using the Algolia Composition API. + */ +internal interface CompositionFacetsSearchService : + SearchService { + + /** + * Client to perform composition operations. + */ + val client: CompositionClient + + /** + * Composition facets service's request. + */ + data class Request( + val compositionID: String, + val query: SearchParamsObject, + val facetAttribute: String, + val facetQuery: String?, + val maxFacetHits: Int?, + ) +} + +/** + * Default implementation of [CompositionFacetsSearchService]. + */ +internal class DefaultCompositionFacetsSearchService( + override val client: CompositionClient, +) : CompositionFacetsSearchService { + + override suspend fun search( + request: CompositionFacetsSearchService.Request, + requestOptions: RequestOptions?, + ): SearchForFacetValuesResponse { + val response = client.searchForFacetValues( + compositionID = request.compositionID, + facetName = request.facetAttribute, + searchForFacetValuesRequest = CompositionSearchForFacetValuesRequest( + params = CompositionSearchForFacetValuesParams( + query = request.facetQuery, + maxFacetHits = request.maxFacetHits, + searchQuery = request.query.toCompositionParams(), + ) + ), + requestOptions = requestOptions, + ) + val result = response.results?.firstOrNull() + ?: error("The composition facet values search response contains no results") + return result.toSearchForFacetValuesResponse() + } +} diff --git a/instantsearch/src/commonMain/kotlin/com/algolia/instantsearch/searcher/composition/internal/CompositionSearchService.kt b/instantsearch/src/commonMain/kotlin/com/algolia/instantsearch/searcher/composition/internal/CompositionSearchService.kt new file mode 100644 index 000000000..aaae27002 --- /dev/null +++ b/instantsearch/src/commonMain/kotlin/com/algolia/instantsearch/searcher/composition/internal/CompositionSearchService.kt @@ -0,0 +1,73 @@ +package com.algolia.instantsearch.searcher.composition.internal + +import com.algolia.client.api.CompositionClient +import com.algolia.client.model.composition.RequestBody +import com.algolia.client.model.search.SearchParamsObject +import com.algolia.client.model.search.SearchResponse +import com.algolia.client.transport.RequestOptions +import com.algolia.instantsearch.filter.FilterGroup +import com.algolia.instantsearch.searcher.multi.internal.SearchService + +/** + * Search service running Algolia Compositions. + */ +internal interface CompositionSearchService : SearchService { + + /** + * Client to perform composition run operations. + */ + val client: CompositionClient + + /** + * Contains a [Set] of [FilterGroup], used to determine the disjunctive facets attributes. + */ + var filterGroups: Set> + + /** + * Composition service's request. + */ + data class Request( + val compositionID: String, + val query: SearchParamsObject, + ) +} + +/** + * Default implementation of [CompositionSearchService]. + * + * The [SearchParamsObject] is converted to composition run parameters and the first result of the + * composition run response is converted back to a regular [SearchResponse], allowing the reuse of + * all the components consuming search responses. + */ +internal class DefaultCompositionSearchService( + override val client: CompositionClient, + override var filterGroups: Set> = setOf(), +) : CompositionSearchService { + + override suspend fun search(request: CompositionSearchService.Request, requestOptions: RequestOptions?): SearchResponse { + val params = request.query + .toCompositionParams() + .annotateDisjunctiveFacets(disjunctiveFacetAttributes()) + val response = client.search( + compositionID = request.compositionID, + requestBody = RequestBody(params), + requestOptions = requestOptions, + ) + val result = response.results.firstOrNull() + ?: error("The composition run response contains no results") + return result.toSearchResponse() + } + + /** + * Attributes of the refined disjunctive (OR group) facets, annotated with the `disjunctive` + * modifier in the requested facets, so that the Composition API computes their facet counts + * disjunctively server-side. + */ + private fun disjunctiveFacetAttributes(): Set { + return filterGroups + .filterIsInstance() + .flatten() + .map { it.attribute } + .toSet() + } +} diff --git a/instantsearch/src/commonMain/kotlin/com/algolia/instantsearch/searcher/composition/internal/DefaultCompositionFacetsSearcher.kt b/instantsearch/src/commonMain/kotlin/com/algolia/instantsearch/searcher/composition/internal/DefaultCompositionFacetsSearcher.kt new file mode 100644 index 000000000..71005df18 --- /dev/null +++ b/instantsearch/src/commonMain/kotlin/com/algolia/instantsearch/searcher/composition/internal/DefaultCompositionFacetsSearcher.kt @@ -0,0 +1,71 @@ +package com.algolia.instantsearch.searcher.composition.internal + +import com.algolia.client.model.search.SearchForFacetValuesResponse +import com.algolia.client.model.search.SearchParamsObject +import com.algolia.client.transport.RequestOptions +import com.algolia.instantsearch.core.searcher.Sequencer +import com.algolia.instantsearch.core.subscription.SubscriptionValue +import com.algolia.instantsearch.searcher.composition.CompositionFacetsSearcher +import com.algolia.instantsearch.searcher.facets.SearchForFacetQuery +import com.algolia.instantsearch.searcher.internal.SearcherExceptionHandler +import com.algolia.instantsearch.searcher.internal.runAsLoading +import com.algolia.instantsearch.searcher.internal.withAlgoliaAgent +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +/** + * The component handling search requests and managing the search sessions. + * This implementation searches for facet values using the Algolia Composition API. + */ +internal class DefaultCompositionFacetsSearcher( + private val searchService: CompositionFacetsSearchService, + override var compositionID: String, + override var query: SearchParamsObject, + override val attribute: String, + override var facetQuery: String?, + override var maxFacetHits: Int?, + override val requestOptions: RequestOptions?, + override val coroutineScope: CoroutineScope, + override val coroutineDispatcher: CoroutineDispatcher, + private val triggerSearchFor: SearchForFacetQuery, +) : CompositionFacetsSearcher { + + override val isLoading: SubscriptionValue = SubscriptionValue(false) + override val error: SubscriptionValue = SubscriptionValue(null) + override val response: SubscriptionValue = SubscriptionValue(null) + + private val exceptionHandler = SearcherExceptionHandler(this) + private val sequencer = Sequencer() + + private val options get() = requestOptions.withAlgoliaAgent() + private val request + get() = CompositionFacetsSearchService.Request(compositionID, query, attribute, facetQuery, maxFacetHits) + + override fun setQuery(text: String?) { + facetQuery = text + } + + override fun searchAsync(): Job { + return coroutineScope.launch(exceptionHandler) { + isLoading.runAsLoading { + response.value = search() + } + }.also { + sequencer.addOperation(it) + } + } + + override suspend fun search(): SearchForFacetValuesResponse? { + if (!triggerSearchFor.trigger(query, attribute, facetQuery)) return null + return withContext(coroutineDispatcher) { + searchService.search(request, options) + } + } + + override fun cancel() { + sequencer.cancelAll() + } +} diff --git a/instantsearch/src/commonMain/kotlin/com/algolia/instantsearch/searcher/composition/internal/DefaultCompositionSearcher.kt b/instantsearch/src/commonMain/kotlin/com/algolia/instantsearch/searcher/composition/internal/DefaultCompositionSearcher.kt new file mode 100644 index 000000000..88a8bdee3 --- /dev/null +++ b/instantsearch/src/commonMain/kotlin/com/algolia/instantsearch/searcher/composition/internal/DefaultCompositionSearcher.kt @@ -0,0 +1,68 @@ +package com.algolia.instantsearch.searcher.composition.internal + +import com.algolia.client.model.search.SearchParamsObject +import com.algolia.client.model.search.SearchResponse +import com.algolia.client.transport.RequestOptions +import com.algolia.instantsearch.core.searcher.Sequencer +import com.algolia.instantsearch.core.subscription.SubscriptionValue +import com.algolia.instantsearch.filter.FilterGroup +import com.algolia.instantsearch.searcher.composition.CompositionSearcher +import com.algolia.instantsearch.searcher.hits.SearchForQuery +import com.algolia.instantsearch.searcher.internal.SearcherExceptionHandler +import com.algolia.instantsearch.searcher.internal.runAsLoading +import com.algolia.instantsearch.searcher.internal.withAlgoliaAgent +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +/** + * The component handling search requests and managing the search sessions. + * This implementation runs an Algolia Composition. + */ +internal class DefaultCompositionSearcher( + private val searchService: CompositionSearchService, + override var compositionID: String, + override var query: SearchParamsObject, + override val requestOptions: RequestOptions?, + override val coroutineScope: CoroutineScope, + override val coroutineDispatcher: CoroutineDispatcher, + private val triggerSearchFor: SearchForQuery, +) : CompositionSearcher { + + override val isLoading: SubscriptionValue = SubscriptionValue(false) + override val error: SubscriptionValue = SubscriptionValue(null) + override val response: SubscriptionValue = SubscriptionValue(null) + override var filterGroups: Set> by searchService::filterGroups + + private val exceptionHandler = SearcherExceptionHandler(this) + private val sequencer = Sequencer() + + private val options get() = requestOptions.withAlgoliaAgent() + + override fun setQuery(text: String?) { + query = query.copy(query = text) + } + + override fun searchAsync(): Job { + return coroutineScope.launch(exceptionHandler) { + isLoading.runAsLoading { + response.value = search() + } + }.also { + sequencer.addOperation(it) + } + } + + override suspend fun search(): SearchResponse? { + if (!triggerSearchFor.trigger(query)) return null + return withContext(coroutineDispatcher) { + searchService.search(CompositionSearchService.Request(compositionID, query), options) + } + } + + override fun cancel() { + sequencer.cancelAll() + } +} diff --git a/instantsearch/src/commonTest/kotlin/searcher/TestCompositionSearcher.kt b/instantsearch/src/commonTest/kotlin/searcher/TestCompositionSearcher.kt new file mode 100644 index 000000000..55ace3f97 --- /dev/null +++ b/instantsearch/src/commonTest/kotlin/searcher/TestCompositionSearcher.kt @@ -0,0 +1,222 @@ +package searcher + +import JsonNoDefaults +import com.algolia.client.api.CompositionClient +import com.algolia.client.configuration.ClientOptions +import com.algolia.client.model.composition.Params +import com.algolia.client.model.composition.SearchResultsItem +import com.algolia.client.model.search.SearchParamsObject +import com.algolia.instantsearch.filter.Filter +import com.algolia.instantsearch.filter.FilterGroup +import com.algolia.instantsearch.filter.state.FilterGroupID +import com.algolia.instantsearch.filter.state.FilterOperator +import com.algolia.instantsearch.filter.state.FilterState +import com.algolia.instantsearch.searcher.composition.CompositionFacetsSearcher +import com.algolia.instantsearch.searcher.composition.CompositionSearcher +import com.algolia.instantsearch.searcher.composition.internal.annotateDisjunctiveFacets +import com.algolia.instantsearch.searcher.composition.internal.toCompositionParams +import com.algolia.instantsearch.searcher.composition.internal.toSearchResponse +import com.algolia.instantsearch.searcher.connectFilterState +import io.ktor.client.engine.mock.MockEngine +import io.ktor.client.engine.mock.respond +import io.ktor.client.engine.mock.respondBadRequest +import io.ktor.http.ContentType +import io.ktor.http.headersOf +import io.ktor.client.plugins.logging.LogLevel +import io.ktor.utils.io.ByteReadChannel +import kotlin.test.Test +import kotlinx.coroutines.test.runTest +import shouldBeNull +import shouldBeTrue +import shouldEqual +import shouldNotBeNull +import com.algolia.client.model.composition.FacetHits as CompositionFacetHits +import com.algolia.client.model.composition.Hit as CompositionHit +import com.algolia.client.model.composition.SearchForFacetValuesResponse as CompositionSearchForFacetValuesResponse +import com.algolia.client.model.composition.SearchForFacetValuesResults as CompositionSearchForFacetValuesResults +import com.algolia.client.model.composition.SearchResponse as CompositionSearchResponse + +class TestCompositionSearcher { + + private val compositionID = "my-composition" + + private val runResponse = CompositionSearchResponse( + results = listOf( + SearchResultsItem( + hits = listOf(CompositionHit(objectID = "obj1"), CompositionHit(objectID = "obj2")), + hitsPerPage = 20, + nbHits = 2, + nbPages = 1, + page = 0, + params = "query=phone", + query = "phone", + compositions = emptyMap(), + facets = mapOf("color" to mapOf("red" to 1, "blue" to 2)), + queryID = "queryID", + processingTimeMS = 7, + ) + ) + ) + + private val facetValuesResponse = CompositionSearchForFacetValuesResponse( + results = listOf( + CompositionSearchForFacetValuesResults( + indexName = "myIndex", + facetHits = listOf(CompositionFacetHits(value = "red", highlighted = "red", count = 3)), + exhaustiveFacetsCount = true, + processingTimeMS = 2, + ) + ) + ) + + private fun mockCompositionClient(engine: MockEngine): CompositionClient { + return CompositionClient( + appId = "A", + apiKey = "B", + options = ClientOptions( + engine = engine, + logLevel = LogLevel.ALL + ) + ) + } + + private fun respondRun(): CompositionClient { + val responseString = JsonNoDefaults.encodeToString(CompositionSearchResponse.serializer(), runResponse) + return mockCompositionClient( + MockEngine { + respond( + headers = headersOf("Content-Type", listOf(ContentType.Application.Json.toString())), + content = ByteReadChannel(responseString) + ) + } + ) + } + + private fun respondFacetValues(): CompositionClient { + val responseString = + JsonNoDefaults.encodeToString(CompositionSearchForFacetValuesResponse.serializer(), facetValuesResponse) + return mockCompositionClient( + MockEngine { + respond( + headers = headersOf("Content-Type", listOf(ContentType.Application.Json.toString())), + content = ByteReadChannel(responseString) + ) + } + ) + } + + @Test + fun searchShouldMapCompositionRunResponse() = runTest { + val searcher = CompositionSearcher( + client = respondRun(), + compositionID = compositionID, + coroutineScope = TestCoroutineScope, + ) + searcher.searchAsync().join() + searcher.response.value.shouldNotBeNull() + val response = searcher.response.value!! + response.hits.map { it.objectID } shouldEqual listOf("obj1", "obj2") + response.nbHits shouldEqual 2 + response.page shouldEqual 0 + response.nbPages shouldEqual 1 + response.hitsPerPage shouldEqual 20 + response.query shouldEqual "phone" + response.queryID shouldEqual "queryID" + response.processingTimeMS shouldEqual 7 + response.facets shouldEqual mapOf("color" to mapOf("red" to 1, "blue" to 2)) + searcher.error.value.shouldBeNull() + } + + @Test + fun searchShouldUpdateError() = runTest { + val searcher = CompositionSearcher( + client = mockCompositionClient(MockEngine { respondBadRequest() }), + compositionID = compositionID, + coroutineScope = TestCoroutineScope, + ) + searcher.searchAsync().join() + searcher.error.value.shouldNotBeNull() + searcher.response.value.shouldBeNull() + } + + @Test + fun setQueryShouldUpdateQuery() { + val searcher = CompositionSearcher( + client = respondRun(), + compositionID = compositionID, + coroutineScope = TestCoroutineScope, + ) + searcher.setQuery("phone") + searcher.query.query shouldEqual "phone" + } + + @Test + fun connectFilterStateShouldUpdateFilters() { + val searcher = CompositionSearcher( + client = respondRun(), + compositionID = compositionID, + coroutineScope = TestCoroutineScope, + ) + val filter = Filter.Facet("color", "red") + val filterState = FilterState(mapOf(FilterGroupID("color", FilterOperator.Or) to setOf(filter))) + searcher.connectFilterState(filterState) + searcher.query.filters shouldEqual "(\"color\":\"red\")" + searcher.filterGroups shouldEqual setOf(FilterGroup.Or.Facet(setOf(filter), name = "color")) + } + + @Test + fun searchForFacetValuesShouldMapResponse() = runTest { + val searcher = CompositionFacetsSearcher( + client = respondFacetValues(), + compositionID = compositionID, + attribute = "color", + coroutineScope = TestCoroutineScope, + ) + searcher.searchAsync().join() + searcher.response.value.shouldNotBeNull() + val response = searcher.response.value!! + response.facetHits.size shouldEqual 1 + response.facetHits.first().value shouldEqual "red" + response.facetHits.first().highlighted shouldEqual "red" + response.facetHits.first().count shouldEqual 3 + response.exhaustiveFacetsCount.shouldBeTrue() + response.processingTimeMS shouldEqual 2 + } + + @Test + fun searchParamsShouldMapToCompositionParams() { + val params = SearchParamsObject( + query = "phone", + filters = "(\"color\":\"red\")", + facets = listOf("color", "brand"), + page = 2, + hitsPerPage = 50, + highlightPreTag = "", + highlightPostTag = "", + ).toCompositionParams() + + params.query shouldEqual "phone" + params.filters shouldEqual "(\"color\":\"red\")" + params.facets shouldEqual listOf("color", "brand") + params.page shouldEqual 2 + params.hitsPerPage shouldEqual 50 + } + + @Test + fun annotateDisjunctiveFacetsShouldTagRefinedAttributes() { + val params = Params(facets = listOf("color", "brand")) + val annotated = params.annotateDisjunctiveFacets(setOf("color")) + annotated.facets shouldEqual listOf("disjunctive(color)", "brand") + } + + @Test + fun searchResultsItemShouldMapToSearchResponse() { + val response = runResponse.results.first().toSearchResponse() + response.hits.map { it.objectID } shouldEqual listOf("obj1", "obj2") + response.query shouldEqual "phone" + response.params shouldEqual "query=phone" + response.nbHits shouldEqual 2 + response.facets shouldEqual mapOf("color" to mapOf("red" to 1, "blue" to 2)) + response.queryID shouldEqual "queryID" + } +}