diff --git a/helper/CHANGELOG.md b/helper/CHANGELOG.md index f902575..1f7de7e 100755 --- a/helper/CHANGELOG.md +++ b/helper/CHANGELOG.md @@ -1,3 +1,7 @@ +## 1.7.0 + + - **FEAT**(composition): add Composition API support with `CompositionSearcher` and `CompositionFacetSearcher`, including `FilterState`, `FacetList`, and Insights integration. + ## 1.6.0 - **FEAT**(response): add userData (#172). diff --git a/helper/README.md b/helper/README.md index 31fd704..346a14a 100644 --- a/helper/README.md +++ b/helper/README.md @@ -27,12 +27,14 @@ framework**. | Component | Description | |--------------------|----------------------------------------------------------------------------------------------------------------| -| [HitsSearcher][0] | Component handling search requests. | -| [FacetSearcher][1] | Component handling search for facet values requests | -| [MultiSearcher][2] | Component handling multi-search experiences aggregating [HitsSearcher][0] and [FacetSearcher][4] | -| [FilterState][3] | Component providing a friendly interface to manage search filters. | -| [FacetList][4] | Component to get and manage facets, lets the user refine their search results by filtering on specific values. | -| [Highlighting][5] | Set of tools to highlight relevant parts of the search results. | +| [HitsSearcher][0] | Component handling search requests. | +| [FacetSearcher][1] | Component handling search for facet values requests | +| [MultiSearcher][2] | Component handling multi-search experiences aggregating [HitsSearcher][0] and [FacetSearcher][4] | +| [CompositionSearcher][6] | Component handling [Composition][8] run requests. | +| [CompositionFacetSearcher][7] | Component handling search for facet values requests on a composition. | +| [FilterState][3] | Component providing a friendly interface to manage search filters. | +| [FacetList][4] | Component to get and manage facets, lets the user refine their search results by filtering on specific values. | +| [Highlighting][5] | Set of tools to highlight relevant parts of the search results. | [0]: lib/src/searcher/hits_searcher.dart @@ -41,3 +43,6 @@ framework**. [3]: lib/src/filter_state.dart [4]: lib/src/facet_list.dart [5]: lib/src/highlighting.dart +[6]: lib/src/searcher/composition_searcher.dart +[7]: lib/src/searcher/composition_facet_searcher.dart +[8]: https://www.algolia.com/doc/guides/building-search-ui/going-further/composition/what-is-composition/js/ diff --git a/helper/example/composition_searcher.dart b/helper/example/composition_searcher.dart new file mode 100644 index 0000000..011aa48 --- /dev/null +++ b/helper/example/composition_searcher.dart @@ -0,0 +1,35 @@ +import 'package:algolia_helper_flutter/algolia_helper_flutter.dart'; + +void main() { + // Create a composition searcher. + // The searcher runs a composition and obtains its results. + final searcher = CompositionSearcher( + applicationID: 'MY_APPLICATION_ID', + apiKey: 'MY_API_KEY', + compositionID: 'MY_COMPOSITION_ID', + ); + + // Run your composition run operations and listen to the results! + searcher.responses.listen((response) { + print("Search query '${response.query}' (${response.nbHits} hits found)"); + for (var hit in response.hits) { + print("> ${hit['name']}"); + } + + // Multifeed compositions expose each feed's results. + final feeds = response.feeds; + if (feeds != null) { + for (var feed in feeds) { + print("Feed '${feed.feedID}': ${feed.nbHits} hits"); + } + } + }); + + searcher.query('a'); + + searcher.eventTracker?.clickedObjects( + eventName: 'clicked objects', + objectIDs: ['object1', 'object2', 'object3'], + positions: [1, 2, 3], + ); +} diff --git a/helper/lib/algolia_helper_flutter.dart b/helper/lib/algolia_helper_flutter.dart index 76dda98..bdc8b30 100644 --- a/helper/lib/algolia_helper_flutter.dart +++ b/helper/lib/algolia_helper_flutter.dart @@ -5,6 +5,7 @@ library algolia_helper_flutter; export 'src/client_options.dart'; +export 'src/composition_searcher_facet_list_extension.dart'; export 'src/disposable.dart'; export 'src/exception.dart'; export 'src/facet_list.dart'; @@ -15,9 +16,15 @@ export 'src/filters.dart'; export 'src/highlighting.dart'; export 'src/highlighting_core.dart'; export 'src/hits_searcher_facet_list_extension.dart'; +export 'src/model/composition_facet_search_response.dart'; +export 'src/model/composition_facet_search_state.dart'; +export 'src/model/composition_response.dart'; +export 'src/model/composition_state.dart'; export 'src/model/facet.dart'; export 'src/model/multi_search_response.dart'; export 'src/model/multi_search_state.dart'; +export 'src/searcher/composition_facet_searcher.dart'; +export 'src/searcher/composition_searcher.dart'; export 'src/searcher/facet_searcher.dart'; export 'src/searcher/hits_searcher.dart'; export 'src/searcher/multi_searcher.dart'; diff --git a/helper/lib/src/composition_searcher_facet_list_extension.dart b/helper/lib/src/composition_searcher_facet_list_extension.dart new file mode 100644 index 0000000..ffe4bed --- /dev/null +++ b/helper/lib/src/composition_searcher_facet_list_extension.dart @@ -0,0 +1,70 @@ +import 'package:algolia_insights/algolia_insights.dart'; +import 'package:meta/meta.dart'; + +import 'facet_list.dart'; +import 'filter_group.dart'; +import 'filter_state.dart'; +import 'filter_state_group_accessor.dart'; +import 'searcher/composition_searcher.dart'; + +extension CompositionSearcherFacetListExtension on CompositionSearcher { + static FilterEventTracker? _makeEventTracker( + CompositionSearcher searcher, + String attribute, + ) { + if (searcher.eventTracker == null) { + return null; + } + + return FilterEventTracker( + searcher.eventTracker!.tracker, + searcher, + attribute, + ); + } + + @experimental + FacetList buildFacetList({ + required FilterState filterState, + required String attribute, + FilterOperator operator = FilterOperator.or, + SelectionMode selectionMode = SelectionMode.multiple, + bool persistent = false, + }) { + // Setup composition state by adding `attribute` to the search state + applyState( + (state) => state.copyWith( + facets: List.from((state.facets ?? [])..add(attribute)), + disjunctiveFacets: operator == FilterOperator.or + ? {...?state.disjunctiveFacets, attribute} + : state.disjunctiveFacets, + ), + ); + + // Extract the Stream> from CompositionSearcher + final facetsStream = responses.map( + (response) => + response.disjunctiveFacets[attribute] ?? + response.facets[attribute] ?? + [], + ); + + // Establish the filter group accessor proxy as selection state + final state = FiltersGroupAccessor( + filterState: filterState, + groupID: FilterGroupID( + attribute, + operator, + ), + attribute: attribute, + ); + + return FacetList( + facetsStream: facetsStream, + state: state, + selectionMode: selectionMode, + persistent: persistent, + eventTracker: _makeEventTracker(this, attribute), + ); + } +} diff --git a/helper/lib/src/lib_version.dart b/helper/lib/src/lib_version.dart index 7b7927e..7e605df 100644 --- a/helper/lib/src/lib_version.dart +++ b/helper/lib/src/lib_version.dart @@ -1 +1 @@ -const libVersion = '1.6.0'; +const libVersion = '1.7.0'; diff --git a/helper/lib/src/model/composition_facet_search_response.dart b/helper/lib/src/model/composition_facet_search_response.dart new file mode 100644 index 0000000..77f4352 --- /dev/null +++ b/helper/lib/src/model/composition_facet_search_response.dart @@ -0,0 +1,40 @@ +import 'facet.dart'; + +/// Search for facet values operation response for a composition. +class CompositionFacetSearchResponse { + /// Creates [CompositionFacetSearchResponse] instance. + CompositionFacetSearchResponse(this.raw) + : facetHits = Facet.fromList( + List>.from( + (raw['facetHits'] as List? ?? []).map( + (e) => Map.from(e as Map), + ), + ), + ); + + /// Raw facet search response + final Map raw; + + /// Search for facet values hits list + final List facetHits; + + /// Whether the count returned for each facets is exhaustive. + bool get exhaustiveFacetsCount => + raw['exhaustiveFacetsCount'] as bool? ?? false; + + /// Time the server took to process the request, in milliseconds. + int get processingTimeMS => raw['processingTimeMS'] as int? ?? 0; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is CompositionFacetSearchResponse && + runtimeType == other.runtimeType && + raw == other.raw; + + @override + int get hashCode => raw.hashCode; + + @override + String toString() => 'CompositionFacetSearchResponse{raw: $raw}'; +} diff --git a/helper/lib/src/model/composition_facet_search_state.dart b/helper/lib/src/model/composition_facet_search_state.dart new file mode 100644 index 0000000..91b1c66 --- /dev/null +++ b/helper/lib/src/model/composition_facet_search_state.dart @@ -0,0 +1,74 @@ +import 'composition_state.dart'; + +/// Represents a search for facet values operation state on a composition, and +/// an abstraction over composition facet search queries. +/// +/// ## Example +/// +/// ```dart +/// final facetSearchState = CompositionFacetSearchState( +/// compositionID: 'MY_COMPOSITION_ID', +/// facet: 'brand', +/// facetQuery: 'samsung', +/// state: const CompositionState(compositionID: 'MY_COMPOSITION_ID'), +/// ); +/// ``` +final class CompositionFacetSearchState { + /// Creates [CompositionFacetSearchState] instance. + const CompositionFacetSearchState({ + required this.compositionID, + required this.facet, + required this.state, + this.facetQuery = '', + }); + + /// Unique Composition ObjectID to search facet values in. + final String compositionID; + + /// Facet name to search for. + final String facet; + + /// Text to search inside the facet's values. + final String facetQuery; + + /// Composition search operation state. + final CompositionState state; + + /// Make a copy of the composition facet search state. + CompositionFacetSearchState copyWith({ + String? compositionID, + String? facet, + String? facetQuery, + CompositionState? state, + }) => + CompositionFacetSearchState( + compositionID: compositionID ?? this.compositionID, + facet: facet ?? this.facet, + facetQuery: facetQuery ?? this.facetQuery, + state: state ?? this.state, + ); + + @override + bool operator ==(Object other) => + identical(this, other) || + other is CompositionFacetSearchState && + runtimeType == other.runtimeType && + compositionID == other.compositionID && + facet == other.facet && + facetQuery == other.facetQuery && + state == other.state; + + @override + int get hashCode => + compositionID.hashCode ^ + facet.hashCode ^ + facetQuery.hashCode ^ + state.hashCode; + + @override + String toString() => 'CompositionFacetSearchState{' + 'compositionID: $compositionID, ' + 'facet: $facet, ' + 'facetQuery: $facetQuery, ' + 'state: $state}'; +} diff --git a/helper/lib/src/model/composition_response.dart b/helper/lib/src/model/composition_response.dart new file mode 100644 index 0000000..ad3c026 --- /dev/null +++ b/helper/lib/src/model/composition_response.dart @@ -0,0 +1,111 @@ +import 'package:algoliasearch/algoliasearch.dart' as algolia; + +import 'facet.dart'; +import 'multi_search_response.dart'; + +/// Composition run operation response. +/// +/// Wraps a single composition run result (a `results` item returned by the +/// Composition run endpoint). When the composition is a multifeed one, the +/// individual feed results are exposed through [feeds] (ordered), and this +/// response points to the first feed. +class CompositionResponse { + /// Creates [CompositionResponse] instance. + CompositionResponse(this.raw, {List? feeds}) + : hits = _hitsFromList(raw['hits'] as List?), + feeds = + feeds == null || feeds.isEmpty ? null : List.unmodifiable(feeds), + disjunctiveFacets = {}, + hierarchicalFacets = {}; + + /// Raw composition result response + final Map raw; + + /// Search hits list + final List hits; + + /// Ordered list of feed results for a multifeed composition. + /// + /// `null` when the composition run returned a single (non-feed) result. + final List? feeds; + + /// A mapping of each facet name to the corresponding facet counts for + /// disjunctive facets. + Map> disjunctiveFacets; + + /// A mapping of each facet name to the corresponding facet counts for + /// hierarchical facets. + Map> hierarchicalFacets; + + /// The ID of the feed, when this response is part of a multifeed composition. + String? get feedID => raw['feedID'] as String?; + + /// Index name used for the composition's main source query. + String? get index => raw['index'] as String?; + + /// An url-encoded string of all query parameters. + String? get params => raw['params'] as String?; + + /// Identifies the query uniquely. + String? get queryID => raw['queryID'] as String?; + + /// An echo of the query text. + String get query => raw['query'] as String? ?? ''; + + /// The maximum number of hits returned per page. + int get hitsPerPage => raw['hitsPerPage'] as int? ?? 0; + + /// The number of hits matched by the query. + int get nbHits => raw['nbHits'] as int? ?? 0; + + /// The number of returned pages. + int get nbPages => raw['nbPages'] as int? ?? 0; + + /// Index of the current page (zero-based). + int get page => raw['page'] as int? ?? 0; + + /// A mapping of each facet name to the corresponding facet counts. + Map> get facets => + Facet.fromMap(raw['facets'] as Map? ?? {}); + + /// Statistics for numerical facets. + Map> get facetsStats => + (raw['facets_stats'] as Map?)?.map( + (key, value) => + MapEntry(key, Map.from(value as Map? ?? {})), + ) ?? + {}; + + /// Time the server took to process the request, in milliseconds. + int get processingTimeMS => raw['processingTimeMS'] as int? ?? 0; + + /// An object with custom data. + Object userData() => raw['userData'] as Object? ?? {}; + + /// Defines how you want to render results in the search interface. + algolia.RenderingContent? get renderingContent => + raw['renderingContent'] != null + ? algolia.RenderingContent.fromJson( + raw['renderingContent'] as Map) + : null; + + static List _hitsFromList(List? data) { + if (data == null) return const []; + return data + .map((hit) => Hit(Map.from(hit as Map))) + .toList(); + } + + @override + bool operator ==(Object other) => + identical(this, other) || + other is CompositionResponse && + runtimeType == other.runtimeType && + raw == other.raw; + + @override + int get hashCode => raw.hashCode; + + @override + String toString() => 'CompositionResponse{raw: $raw}'; +} diff --git a/helper/lib/src/model/composition_state.dart b/helper/lib/src/model/composition_state.dart new file mode 100644 index 0000000..efe86a4 --- /dev/null +++ b/helper/lib/src/model/composition_state.dart @@ -0,0 +1,345 @@ +import '../extensions.dart'; +import '../filter_group.dart'; + +/// Represents a composition run operation state, and an abstraction over +/// composition search queries. +/// +/// A [CompositionState] targets a single composition identified by its +/// [compositionID] and exposes the subset of search parameters supported by +/// the Composition run endpoint (`/1/compositions/{compositionID}/run`). +/// +/// ## Example +/// +/// ```dart +/// const compositionState = CompositionState( +/// compositionID: 'MY_COMPOSITION_ID', +/// query: 'shoes', +/// page: 1, +/// hitsPerPage: 20, +/// ); +/// ``` +class CompositionState { + /// Creates [CompositionState] instance. + const CompositionState({ + required this.compositionID, + this.query, + this.page, + this.hitsPerPage, + this.facets, + this.disjunctiveFacets, + this.filterGroups, + this.facetFilters, + this.numericFilters, + this.optionalFilters, + this.ruleContexts, + this.analytics, + this.analyticsTags, + this.clickAnalytics, + this.userToken, + this.enableABTest, + this.enablePersonalization, + this.enableReRanking, + this.enableRules, + this.sortBy, + this.relevancyStrictness, + this.getRankingInfo, + this.naturalLanguages, + this.queryLanguages, + this.aroundLatLng, + this.aroundLatLngViaIP, + this.aroundRadius, + this.aroundPrecision, + this.minimumAroundRadius, + this.insideBoundingBox, + this.insidePolygon, + this.injectedItems, + }); + + /// Unique Composition ObjectID to run. + final String compositionID; + + /// Search query string. + final String? query; + + /// Search page number. + final int? page; + + /// Number of hits per page. + final int? hitsPerPage; + + /// Search facets list. + final List? facets; + + /// Disjunctive facets list. + /// + /// Disjunctive facets are annotated with the `disjunctive(...)` modifier in + /// the composition `facets` parameter when they have a selected value. + final Set? disjunctiveFacets; + + /// Set of filter groups. + final Set? filterGroups; + + /// Filter hits by facet value. + final List? facetFilters; + + /// Filter on numeric attributes. + final List? numericFilters; + + /// Create filters for ranking purposes, where records that match the filter + /// are ranked highest. + final List? optionalFilters; + + /// Composition rule contexts. + final List? ruleContexts; + + /// Whether the current query will be taken into account in the Analytics. + final bool? analytics; + + /// Tags to apply to the query for segmenting analytics data. + final List? analyticsTags; + + /// Add a query ID parameter to the response for tracking click and conversion + /// events. + final bool? clickAnalytics; + + /// Associates a certain user token with the current search. + final String? userToken; + + /// Whether to enable index level A/B testing for this run request. + final bool? enableABTest; + + /// Whether to enable Personalization. + final bool? enablePersonalization; + + /// Whether this search will use Dynamic Re-Ranking. + final bool? enableReRanking; + + /// Whether to enable composition rules. + final bool? enableRules; + + /// Indicates which sorting strategy to apply for the request. + final String? sortBy; + + /// Relevancy threshold below which less relevant results aren't included in + /// the results. + final int? relevancyStrictness; + + /// Whether the run response should include detailed ranking information. + final bool? getRankingInfo; + + /// ISO language codes that adjust settings that are useful for processing + /// natural language queries. + final List? naturalLanguages; + + /// Languages for language-specific query processing steps. + final List? queryLanguages; + + /// Coordinates for the center of a circle, expressed as a comma-separated + /// string of latitude and longitude. + final String? aroundLatLng; + + /// Whether to obtain the coordinates from the request's IP address. + final bool? aroundLatLngViaIP; + + /// Maximum radius for a geo search (in meters). Value must be an [int] or + /// `'all'`. + final dynamic aroundRadius; + + /// Precision of a geo search (in meters). + final int? aroundPrecision; + + /// Minimum radius (in meters) for a search around a location when + /// `aroundRadius` isn't set. + final int? minimumAroundRadius; + + /// Search inside a rectangular area (in geo coordinates). + final List>? insideBoundingBox; + + /// Coordinates of a polygon in which to search. + final List>? insidePolygon; + + /// A map of externally injected objectID groups from an external source. + /// + /// Keys are group identifiers, values are the raw JSON representation of an + /// external injected item (`{'items': [...]}`). + final Map? injectedItems; + + /// Make a copy of the composition state. + CompositionState copyWith({ + String? compositionID, + String? query, + int? page, + int? hitsPerPage, + List? facets, + Set? disjunctiveFacets, + Set? filterGroups, + List? facetFilters, + List? numericFilters, + List? optionalFilters, + List? ruleContexts, + bool? analytics, + List? analyticsTags, + bool? clickAnalytics, + String? userToken, + bool? enableABTest, + bool? enablePersonalization, + bool? enableReRanking, + bool? enableRules, + String? sortBy, + int? relevancyStrictness, + bool? getRankingInfo, + List? naturalLanguages, + List? queryLanguages, + String? aroundLatLng, + bool? aroundLatLngViaIP, + dynamic aroundRadius, + int? aroundPrecision, + int? minimumAroundRadius, + List>? insideBoundingBox, + List>? insidePolygon, + Map? injectedItems, + }) => + CompositionState( + compositionID: compositionID ?? this.compositionID, + query: query ?? this.query, + page: page ?? this.page, + hitsPerPage: hitsPerPage ?? this.hitsPerPage, + facets: facets ?? this.facets, + disjunctiveFacets: disjunctiveFacets ?? this.disjunctiveFacets, + filterGroups: filterGroups ?? this.filterGroups, + facetFilters: facetFilters ?? this.facetFilters, + numericFilters: numericFilters ?? this.numericFilters, + optionalFilters: optionalFilters ?? this.optionalFilters, + ruleContexts: ruleContexts ?? this.ruleContexts, + analytics: analytics ?? this.analytics, + analyticsTags: analyticsTags ?? this.analyticsTags, + clickAnalytics: clickAnalytics ?? this.clickAnalytics, + userToken: userToken ?? this.userToken, + enableABTest: enableABTest ?? this.enableABTest, + enablePersonalization: + enablePersonalization ?? this.enablePersonalization, + enableReRanking: enableReRanking ?? this.enableReRanking, + enableRules: enableRules ?? this.enableRules, + sortBy: sortBy ?? this.sortBy, + relevancyStrictness: relevancyStrictness ?? this.relevancyStrictness, + getRankingInfo: getRankingInfo ?? this.getRankingInfo, + naturalLanguages: naturalLanguages ?? this.naturalLanguages, + queryLanguages: queryLanguages ?? this.queryLanguages, + aroundLatLng: aroundLatLng ?? this.aroundLatLng, + aroundLatLngViaIP: aroundLatLngViaIP ?? this.aroundLatLngViaIP, + aroundRadius: aroundRadius ?? this.aroundRadius, + aroundPrecision: aroundPrecision ?? this.aroundPrecision, + minimumAroundRadius: minimumAroundRadius ?? this.minimumAroundRadius, + insideBoundingBox: insideBoundingBox ?? this.insideBoundingBox, + insidePolygon: insidePolygon ?? this.insidePolygon, + injectedItems: injectedItems ?? this.injectedItems, + ); + + @override + bool operator ==(Object other) => + identical(this, other) || + other is CompositionState && + runtimeType == other.runtimeType && + compositionID == other.compositionID && + query == other.query && + page == other.page && + hitsPerPage == other.hitsPerPage && + facets.equals(other.facets) && + disjunctiveFacets.equals(other.disjunctiveFacets) && + filterGroups.equals(other.filterGroups) && + facetFilters.equals(other.facetFilters) && + numericFilters.equals(other.numericFilters) && + optionalFilters.equals(other.optionalFilters) && + ruleContexts.equals(other.ruleContexts) && + analytics == other.analytics && + analyticsTags.equals(other.analyticsTags) && + clickAnalytics == other.clickAnalytics && + userToken == other.userToken && + enableABTest == other.enableABTest && + enablePersonalization == other.enablePersonalization && + enableReRanking == other.enableReRanking && + enableRules == other.enableRules && + sortBy == other.sortBy && + relevancyStrictness == other.relevancyStrictness && + getRankingInfo == other.getRankingInfo && + naturalLanguages.equals(other.naturalLanguages) && + queryLanguages.equals(other.queryLanguages) && + aroundLatLng == other.aroundLatLng && + aroundLatLngViaIP == other.aroundLatLngViaIP && + aroundRadius == other.aroundRadius && + aroundPrecision == other.aroundPrecision && + minimumAroundRadius == other.minimumAroundRadius && + insideBoundingBox.equals(other.insideBoundingBox) && + insidePolygon.equals(other.insidePolygon) && + injectedItems.equals(other.injectedItems); + + @override + int get hashCode => + compositionID.hashCode ^ + query.hashCode ^ + page.hashCode ^ + hitsPerPage.hashCode ^ + facets.hashing() ^ + disjunctiveFacets.hashing() ^ + filterGroups.hashing() ^ + facetFilters.hashing() ^ + numericFilters.hashing() ^ + optionalFilters.hashing() ^ + ruleContexts.hashing() ^ + analytics.hashCode ^ + analyticsTags.hashing() ^ + clickAnalytics.hashCode ^ + userToken.hashCode ^ + enableABTest.hashCode ^ + enablePersonalization.hashCode ^ + enableReRanking.hashCode ^ + enableRules.hashCode ^ + sortBy.hashCode ^ + relevancyStrictness.hashCode ^ + getRankingInfo.hashCode ^ + naturalLanguages.hashing() ^ + queryLanguages.hashing() ^ + aroundLatLng.hashCode ^ + aroundLatLngViaIP.hashCode ^ + aroundRadius.hashCode ^ + aroundPrecision.hashCode ^ + minimumAroundRadius.hashCode ^ + insideBoundingBox.hashing() ^ + insidePolygon.hashing() ^ + injectedItems.hashing(); + + @override + String toString() => 'CompositionState{' + 'compositionID: $compositionID, ' + 'query: $query, ' + 'page: $page, ' + 'hitsPerPage: $hitsPerPage, ' + 'facets: $facets, ' + 'disjunctiveFacets: $disjunctiveFacets, ' + 'filterGroups: $filterGroups, ' + 'facetFilters: $facetFilters, ' + 'numericFilters: $numericFilters, ' + 'optionalFilters: $optionalFilters, ' + 'ruleContexts: $ruleContexts, ' + 'analytics: $analytics, ' + 'analyticsTags: $analyticsTags, ' + 'clickAnalytics: $clickAnalytics, ' + 'userToken: $userToken, ' + 'enableABTest: $enableABTest, ' + 'enablePersonalization: $enablePersonalization, ' + 'enableReRanking: $enableReRanking, ' + 'enableRules: $enableRules, ' + 'sortBy: $sortBy, ' + 'relevancyStrictness: $relevancyStrictness, ' + 'getRankingInfo: $getRankingInfo, ' + 'naturalLanguages: $naturalLanguages, ' + 'queryLanguages: $queryLanguages, ' + 'aroundLatLng: $aroundLatLng, ' + 'aroundLatLngViaIP: $aroundLatLngViaIP, ' + 'aroundRadius: $aroundRadius, ' + 'aroundPrecision: $aroundPrecision, ' + 'minimumAroundRadius: $minimumAroundRadius, ' + 'insideBoundingBox: $insideBoundingBox, ' + 'insidePolygon: $insidePolygon, ' + 'injectedItems: $injectedItems}'; +} diff --git a/helper/lib/src/searcher/composition_facet_searcher.dart b/helper/lib/src/searcher/composition_facet_searcher.dart new file mode 100644 index 0000000..1015100 --- /dev/null +++ b/helper/lib/src/searcher/composition_facet_searcher.dart @@ -0,0 +1,287 @@ +import 'package:logging/logging.dart'; +import 'package:meta/meta.dart'; +import 'package:rxdart/rxdart.dart'; + +import '../client_options.dart'; +import '../disposable.dart'; +import '../disposable_mixin.dart'; +import '../logger.dart'; +import '../model/composition_facet_search_response.dart'; +import '../model/composition_facet_search_state.dart'; +import '../model/composition_state.dart'; +import '../service/algolia_composition_facet_search_service.dart'; +import '../service/composition_facet_search_service.dart'; + +/// Algolia Helpers entry point for composition facet search requests and +/// managing search sessions. +/// +/// [CompositionFacetSearcher] facilitates search for facet values operations on +/// a composition's main source index. It debounces distinct state changes, +/// cancels ongoing requests on new ones, and exposes the results as a stream. +/// +/// ## Create Composition Facet Searcher +/// +/// Instantiate [CompositionFacetSearcher] using the default constructor: +/// +/// ```dart +/// final facetSearcher = CompositionFacetSearcher( +/// applicationID: 'MY_APPLICATION_ID', +/// apiKey: 'MY_API_KEY', +/// compositionID: 'MY_COMPOSITION_ID', +/// facet: 'MY_FACET_ATTRIBUTE', +/// ); +/// ``` +/// +/// Or, using the [CompositionFacetSearcher.create] factory: +/// +/// ```dart +/// final facetSearcher = CompositionFacetSearcher.create( +/// applicationID: 'MY_APPLICATION_ID', +/// apiKey: 'MY_API_KEY', +/// state: CompositionFacetSearchState( +/// compositionID: 'MY_COMPOSITION_ID', +/// facet: 'MY_FACET_ATTRIBUTE', +/// state: const CompositionState(compositionID: 'MY_COMPOSITION_ID'), +/// ), +/// ); +/// ``` +/// +/// ## Dispose +/// +/// Call [dispose] to release underlying resources: +/// +/// ```dart +/// facetSearcher.dispose(); +/// ``` +abstract interface class CompositionFacetSearcher implements Disposable { + /// CompositionFacetSearcher's factory. + factory CompositionFacetSearcher({ + required String applicationID, + required String apiKey, + required String compositionID, + required String facet, + Duration debounce = const Duration(milliseconds: 100), + ClientOptions? options, + }) => + _CompositionFacetSearcher( + applicationID: applicationID, + apiKey: apiKey, + state: CompositionFacetSearchState( + compositionID: compositionID, + facet: facet, + state: CompositionState(compositionID: compositionID), + ), + debounce: debounce, + options: options, + ); + + /// CompositionFacetSearcher's factory. + factory CompositionFacetSearcher.create({ + required String applicationID, + required String apiKey, + required CompositionFacetSearchState state, + Duration debounce = const Duration(milliseconds: 100), + ClientOptions? options, + }) => + _CompositionFacetSearcher( + applicationID: applicationID, + apiKey: apiKey, + state: state, + debounce: debounce, + options: options, + ); + + /// Creates [CompositionFacetSearcher] using a custom + /// [CompositionFacetSearchService]. + @internal + factory CompositionFacetSearcher.custom( + CompositionFacetSearchService service, + CompositionFacetSearchState state, [ + Duration debounce = const Duration(milliseconds: 100), + ]) => + _CompositionFacetSearcher.create(service, state, debounce); + + /// Facet search state stream + Stream get state; + + /// Facet search results stream + Stream get responses; + + /// Set facet query string. + void query(String query); + + /// Get current [CompositionFacetSearchState]. + CompositionFacetSearchState snapshot(); + + /// Get latest [CompositionFacetSearchResponse]. + CompositionFacetSearchResponse? get lastResponse; + + /// Apply facet search state configuration. + void applyState( + CompositionFacetSearchState Function(CompositionFacetSearchState state) + config, + ); + + /// Re-run the last facet search query + void rerun(); +} + +/// Internal request wrapper allowing [rerun] to force a distinct emission. +@immutable +class _CompositionFacetRequest { + const _CompositionFacetRequest(this.state, [this.attempts = 1]); + + final CompositionFacetSearchState state; + final int attempts; + + _CompositionFacetRequest copyWith({ + CompositionFacetSearchState? state, + int? attempts, + }) => + _CompositionFacetRequest( + state ?? this.state, + attempts ?? this.attempts, + ); + + @override + bool operator ==(Object other) => + identical(this, other) || + other is _CompositionFacetRequest && + runtimeType == other.runtimeType && + state == other.state && + attempts == other.attempts; + + @override + int get hashCode => state.hashCode ^ attempts.hashCode; +} + +/// Default implementation of [CompositionFacetSearcher]. +class _CompositionFacetSearcher + with DisposableMixin + implements CompositionFacetSearcher { + /// CompositionFacetSearcher's factory. + factory _CompositionFacetSearcher({ + required String applicationID, + required String apiKey, + required CompositionFacetSearchState state, + Duration debounce = const Duration(milliseconds: 100), + ClientOptions? options, + }) { + final service = AlgoliaCompositionFacetSearchService( + applicationID: applicationID, + apiKey: apiKey, + options: options, + ); + return _CompositionFacetSearcher.create( + service, + state, + debounce, + ); + } + + /// CompositionFacetSearcher's constructor, for internal and test use only. + _CompositionFacetSearcher.create( + CompositionFacetSearchService searchService, + CompositionFacetSearchState state, [ + Duration debounce = const Duration(milliseconds: 100), + ]) : this._( + searchService, + BehaviorSubject.seeded(_CompositionFacetRequest(state)), + debounce, + ); + + /// CompositionFacetSearcher's private constructor + _CompositionFacetSearcher._( + this.searchService, + this._request, + this.debounce, + ) { + _subscriptions.add(_responses.connect()); + } + + /// Facet search state stream + @override + Stream get state => + _request.stream.map((request) => request.state); + + /// Facet search results stream + @override + Stream get responses => _responses; + + /// Service handling facet search requests + final CompositionFacetSearchService searchService; + + /// Facet search state debounce duration + final Duration debounce; + + /// Facet search state subject + final BehaviorSubject<_CompositionFacetRequest> _request; + + /// Facet search responses subject + late final _responses = _request.stream + .debounceTime(debounce) + .distinct() + .switchMap((req) => Stream.fromFuture(searchService.search(req.state))) + .doOnData((value) { + lastResponse = value; + }).publish(); + + /// Events logger + final Logger _log = algoliaLogger('CompositionFacetSearcher'); + + /// Streams subscriptions composite. + final CompositeSubscription _subscriptions = CompositeSubscription(); + + @override + void query(String query) { + applyState((state) => state.copyWith(facetQuery: query)); + } + + @override + CompositionFacetSearchState snapshot() => _request.value.state; + + /// Get latest facet search response + @override + CompositionFacetSearchResponse? lastResponse; + + /// Apply facet search state configuration. + @override + void applyState( + CompositionFacetSearchState Function(CompositionFacetSearchState state) + config, + ) { + _updateState((state) => config(state)); + } + + /// Apply changes to the current state + void _updateState( + CompositionFacetSearchState Function(CompositionFacetSearchState state) + apply, + ) { + if (_request.isClosed) { + _log.warning('modifying disposed instance'); + return; + } + final current = _request.value; + final newState = apply(current.state); + _request.sink.add(_CompositionFacetRequest(newState)); + } + + @override + void rerun() { + final current = _request.value; + final request = current.copyWith( + state: current.state, + attempts: current.attempts + 1, + ); + _log.fine('Rerun request: $request'); + _request.sink.add(request); + } + + @override + void doDispose() { + _log.fine('CompositionFacetSearcher disposed'); + _request.close(); + _subscriptions.dispose(); + } +} diff --git a/helper/lib/src/searcher/composition_searcher.dart b/helper/lib/src/searcher/composition_searcher.dart new file mode 100644 index 0000000..f75231c --- /dev/null +++ b/helper/lib/src/searcher/composition_searcher.dart @@ -0,0 +1,360 @@ +import 'dart:async'; + +import 'package:algolia_insights/algolia_insights.dart'; +import 'package:logging/logging.dart'; +import 'package:meta/meta.dart'; +import 'package:rxdart/rxdart.dart'; + +import '../client_options.dart'; +import '../disposable.dart'; +import '../disposable_mixin.dart'; +import '../filter_state.dart'; +import '../logger.dart'; +import '../model/composition_response.dart'; +import '../model/composition_state.dart'; +import '../service/algolia_composition_search_service.dart'; +import '../service/composition_search_service.dart'; + +/// Algolia Helpers entry point for running an Algolia +/// [Composition](https://www.algolia.com/doc/guides/building-search-ui/going-further/composition/what-is-composition/js/), +/// the component handling composition run requests and managing search +/// sessions. +/// +/// [CompositionSearcher] targets a single composition identified by its +/// `compositionID` and has the following behavior: +/// +/// 1. Distinct state changes (including initial state) trigger a run operation +/// 2. State changes are debounced +/// 3. On new run request, previous ongoing calls are cancelled +/// +/// ## Create Composition Searcher +/// +/// Instantiate [CompositionSearcher] using the default constructor: +/// +/// ```dart +/// final compositionSearcher = CompositionSearcher( +/// applicationID: 'MY_APPLICATION_ID', +/// apiKey: 'MY_API_KEY', +/// compositionID: 'MY_COMPOSITION_ID', +/// ); +/// ``` +/// +/// Or, using the [CompositionSearcher.create] factory: +/// +/// ```dart +/// final compositionSearcher = CompositionSearcher.create( +/// applicationID: 'MY_APPLICATION_ID', +/// apiKey: 'MY_API_KEY', +/// state: const CompositionState( +/// compositionID: 'MY_COMPOSITION_ID', +/// query: 'shoes', +/// ), +/// ); +/// ``` +/// +/// ## Run composition requests +/// +/// Execute run queries using the [query] method: +/// +/// ```dart +/// compositionSearcher.query('book'); +/// ``` +/// +/// Or, use [applyState] for more parameters: +/// +/// ```dart +/// compositionSearcher.applyState((state) => +/// state.copyWith(query: 'book', page: 0)); +/// ``` +/// +/// ## Get search results +/// +/// Listen to [responses] to get composition responses: +/// +/// ```dart +/// compositionSearcher.responses.listen((response) { +/// print('${response.nbHits} hits found'); +/// for (var hit in response.hits) { +/// print("> ${hit['objectID']}"); +/// } +/// }); +/// ``` +/// +/// ## Dispose +/// +/// Call [dispose] to release underlying resources: +/// +/// ```dart +/// compositionSearcher.dispose(); +/// ``` +abstract interface class CompositionSearcher + implements Disposable, EventDataDelegate { + /// CompositionSearcher's factory. + factory CompositionSearcher({ + required String applicationID, + required String apiKey, + required String compositionID, + Duration debounce = const Duration(milliseconds: 100), + bool insights = false, + ClientOptions? options, + }) => + _CompositionSearcher( + applicationID: applicationID, + apiKey: apiKey, + state: CompositionState( + compositionID: compositionID, + clickAnalytics: true, + ), + debounce: debounce, + insights: insights, + options: options, + ); + + /// CompositionSearcher's factory. + factory CompositionSearcher.create({ + required String applicationID, + required String apiKey, + required CompositionState state, + Duration debounce = const Duration(milliseconds: 100), + bool insights = false, + ClientOptions? options, + }) => + _CompositionSearcher( + applicationID: applicationID, + apiKey: apiKey, + state: state.copyWith(clickAnalytics: true), + debounce: debounce, + insights: insights, + options: options, + ); + + /// Creates [CompositionSearcher] using a custom [CompositionSearchService]. + @internal + factory CompositionSearcher.custom( + CompositionSearchService searchService, + EventTracker? eventTracker, + CompositionState state, [ + Duration debounce = const Duration(milliseconds: 100), + ]) => + _CompositionSearcher.create( + searchService, + eventTracker, + state, + debounce, + ); + + /// Composition events tracker. + HitsEventTracker? get eventTracker; + + /// Composition state stream + Stream get state; + + /// Composition results stream + Stream get responses; + + /// Set query string. + void query(String query); + + /// Get current [CompositionState]. + CompositionState snapshot(); + + /// Get latest [CompositionResponse]. + CompositionResponse? get lastResponse; + + /// Apply composition state configuration. + void applyState(CompositionState Function(CompositionState state) config); + + /// Re-run the last composition query + void rerun(); +} + +/// Extensions over [CompositionSearcher] +extension CompositionSearcherExt on CompositionSearcher { + /// Creates a connection between [CompositionSearcher] and [FilterState]. + StreamSubscription connectFilterState(FilterState filterState) => + filterState.filters.listen( + (filters) => applyState( + (state) => state.copyWith(filterGroups: filters.toFilterGroups()), + ), + ); +} + +/// Internal request wrapper allowing [rerun] to force a distinct emission. +@immutable +class _CompositionRequest { + const _CompositionRequest(this.state, [this.attempts = 1]); + + final CompositionState state; + final int attempts; + + _CompositionRequest copyWith({CompositionState? state, int? attempts}) => + _CompositionRequest(state ?? this.state, attempts ?? this.attempts); + + @override + bool operator ==(Object other) => + identical(this, other) || + other is _CompositionRequest && + runtimeType == other.runtimeType && + state == other.state && + attempts == other.attempts; + + @override + int get hashCode => state.hashCode ^ attempts.hashCode; +} + +/// Default implementation of [CompositionSearcher]. +final class _CompositionSearcher + with DisposableMixin + implements CompositionSearcher { + /// CompositionSearcher's factory. + factory _CompositionSearcher({ + required String applicationID, + required String apiKey, + required CompositionState state, + Duration debounce = const Duration(milliseconds: 100), + bool insights = false, + ClientOptions? options, + }) { + final service = AlgoliaCompositionSearchService( + applicationID: applicationID, + apiKey: apiKey, + options: options, + ); + + EventTracker? eventTracker; + if (insights) { + eventTracker = Insights( + applicationID: applicationID, + apiKey: apiKey, + ); + } + + return _CompositionSearcher.create( + service, + eventTracker, + state, + debounce, + ); + } + + /// CompositionSearcher's constructor, for internal and test use only. + _CompositionSearcher.create( + CompositionSearchService searchService, + EventTracker? eventTracker, + CompositionState state, [ + Duration debounce = const Duration(milliseconds: 100), + ]) : this._( + searchService, + eventTracker, + BehaviorSubject.seeded(_CompositionRequest(state)), + debounce, + ); + + /// CompositionSearcher's private constructor + _CompositionSearcher._( + this.searchService, + EventTracker? eventTracker, + this._request, + this.debounce, + ) { + if (eventTracker != null) { + this.eventTracker = HitsEventTracker(eventTracker, this); + } + _subscriptions.add(_responses.connect()); + } + + /// Composition state stream + @override + Stream get state => + _request.stream.map((request) => request.state); + + /// Composition results stream + @override + Stream get responses => _responses; + + /// Service handling composition run requests + final CompositionSearchService searchService; + + @override + HitsEventTracker? eventTracker; + + /// Composition state debounce duration + final Duration debounce; + + /// Composition state subject + final BehaviorSubject<_CompositionRequest> _request; + + /// Composition responses subject + late final _responses = _request.stream + .debounceTime(debounce) + .distinct() + .switchMap((req) => Stream.fromFuture(searchService.search(req.state))) + .doOnData((value) { + lastResponse = value; + eventTracker?.viewedObjects( + eventName: 'Hits Viewed', + objectIDs: value.hits.map((hit) => hit['objectID'].toString()).toList(), + ); + }).publish(); + + /// Events logger + final Logger _log = algoliaLogger('CompositionSearcher'); + + /// Streams subscriptions composite. + final CompositeSubscription _subscriptions = CompositeSubscription(); + + /// Set query string. + @override + void query(String query) { + _updateState((state) => state.copyWith(query: query)); + } + + /// Get current [CompositionState]. + @override + CompositionState snapshot() => _request.value.state; + + /// Get latest composition response + @override + CompositionResponse? lastResponse; + + /// Apply composition state configuration. + @override + void applyState(CompositionState Function(CompositionState state) config) { + _updateState((state) => config(state)); + } + + /// Apply changes to the current state + void _updateState(CompositionState Function(CompositionState state) apply) { + if (_request.isClosed) { + _log.warning('modifying disposed instance'); + return; + } + final current = _request.value; + final newState = apply(current.state); + _request.sink.add(_CompositionRequest(newState)); + } + + @override + void rerun() { + final current = _request.value; + final request = current.copyWith( + state: current.state, + attempts: current.attempts + 1, + ); + _log.fine('Rerun request: $request'); + _request.sink.add(request); + } + + @override + void doDispose() { + _log.fine('CompositionSearcher disposed'); + _request.close(); + _subscriptions.dispose(); + } + + @override + String get indexName => lastResponse?.index ?? snapshot().compositionID; + + @override + String? get queryID => lastResponse?.queryID; +} diff --git a/helper/lib/src/service/algolia_composition_facet_search_service.dart b/helper/lib/src/service/algolia_composition_facet_search_service.dart new file mode 100644 index 0000000..edc899f --- /dev/null +++ b/helper/lib/src/service/algolia_composition_facet_search_service.dart @@ -0,0 +1,60 @@ +import 'package:algolia_client_composition/algolia_client_composition.dart' + as composition; +import 'package:logging/logging.dart'; + +import '../client_options.dart'; +import '../logger.dart'; +import '../model/composition_facet_search_response.dart'; +import '../model/composition_facet_search_state.dart'; +import 'client_options.dart'; +import 'composition_client_extensions.dart'; +import 'composition_facet_search_service.dart'; + +/// [CompositionFacetSearchService] implementation backed by the Algolia +/// [composition.CompositionClient]. +class AlgoliaCompositionFacetSearchService + implements CompositionFacetSearchService { + /// Creates [AlgoliaCompositionFacetSearchService] instance. + AlgoliaCompositionFacetSearchService({ + required String applicationID, + required String apiKey, + ClientOptions? options, + }) : this.create( + composition.CompositionClient( + appId: applicationID, + apiKey: apiKey, + options: createClientOptions(options), + ), + ); + + /// Creates [AlgoliaCompositionFacetSearchService] instance. + AlgoliaCompositionFacetSearchService.create( + this._client, + ) : _log = algoliaLogger('CompositionFacetSearchService'); + + /// Search events logger. + final Logger _log; + + /// Algolia Composition API client + final composition.CompositionClient _client; + + @override + Future search( + CompositionFacetSearchState state, + ) async { + _log.fine('run composition facet search with state: $state'); + try { + final response = await _client.searchForFacetValues( + compositionID: state.compositionID, + facetName: state.facet, + searchForFacetValuesRequest: state.toRequest(), + ); + final result = response.toFacetSearchResponse(); + _log.fine('received response: $result'); + return result; + } catch (exception) { + _log.severe('exception: $exception'); + throw launderCompositionException(exception); + } + } +} diff --git a/helper/lib/src/service/algolia_composition_search_service.dart b/helper/lib/src/service/algolia_composition_search_service.dart new file mode 100644 index 0000000..9f58d70 --- /dev/null +++ b/helper/lib/src/service/algolia_composition_search_service.dart @@ -0,0 +1,56 @@ +import 'package:algolia_client_composition/algolia_client_composition.dart' + as composition; +import 'package:logging/logging.dart'; + +import '../client_options.dart'; +import '../logger.dart'; +import '../model/composition_response.dart'; +import '../model/composition_state.dart'; +import 'client_options.dart'; +import 'composition_client_extensions.dart'; +import 'composition_search_service.dart'; + +/// [CompositionSearchService] implementation backed by the Algolia +/// [composition.CompositionClient]. +class AlgoliaCompositionSearchService implements CompositionSearchService { + /// Creates [AlgoliaCompositionSearchService] instance. + AlgoliaCompositionSearchService({ + required String applicationID, + required String apiKey, + ClientOptions? options, + }) : this.create( + composition.CompositionClient( + appId: applicationID, + apiKey: apiKey, + options: createClientOptions(options), + ), + ); + + /// Creates [AlgoliaCompositionSearchService] instance. + AlgoliaCompositionSearchService.create( + this._client, + ) : _log = algoliaLogger('CompositionSearchService'); + + /// Search events logger. + final Logger _log; + + /// Algolia Composition API client + final composition.CompositionClient _client; + + @override + Future search(CompositionState state) async { + _log.fine('run composition search with state: $state'); + try { + final response = await _client.search( + compositionID: state.compositionID, + requestBody: state.toRequestBody(), + ); + final result = response.toCompositionResponse(); + _log.fine('received response: $result'); + return result; + } catch (exception) { + _log.severe('exception: $exception'); + throw launderCompositionException(exception); + } + } +} diff --git a/helper/lib/src/service/composition_client_extensions.dart b/helper/lib/src/service/composition_client_extensions.dart new file mode 100644 index 0000000..21820da --- /dev/null +++ b/helper/lib/src/service/composition_client_extensions.dart @@ -0,0 +1,151 @@ +import 'package:algolia_client_composition/algolia_client_composition.dart' + as composition; +import 'package:collection/collection.dart'; + +import '../exception.dart'; +import '../extensions.dart'; +import '../filter.dart'; +import '../filter_group.dart'; +import '../filter_group_converter.dart'; +import '../model/composition_facet_search_response.dart'; +import '../model/composition_facet_search_state.dart'; +import '../model/composition_response.dart'; +import '../model/composition_state.dart'; + +/// Coerce an [composition.AlgoliaApiException] to a [SearchError]. +Exception launderCompositionException(dynamic error) => error + is composition.AlgoliaApiException + ? SearchError({'message': error.error.toString()}, error.statusCode, error) + : SearchError({'message': error.toString()}, 0, error); + +extension CompositionStateExt on CompositionState { + /// Build a Composition run [composition.RequestBody] from this state. + composition.RequestBody toRequestBody() => + composition.RequestBody(params: _toParams()); + + /// Build a composition [composition.Params] from this state. + composition.Params _toParams() { + final filters = filterGroups?.let( + (it) => const FilterGroupConverter().sql(it), + ); + return composition.Params( + query: query, + page: page, + hitsPerPage: hitsPerPage, + facets: _buildFacets(), + filters: filters, + facetFilters: facetFilters, + numericFilters: numericFilters, + optionalFilters: optionalFilters, + ruleContexts: ruleContexts, + analytics: analytics, + analyticsTags: analyticsTags, + clickAnalytics: clickAnalytics, + userToken: userToken, + enableABTest: enableABTest, + enablePersonalization: enablePersonalization, + enableReRanking: enableReRanking, + enableRules: enableRules, + sortBy: sortBy, + relevancyStrictness: relevancyStrictness, + getRankingInfo: getRankingInfo, + naturalLanguages: _toLanguages(naturalLanguages), + queryLanguages: _toLanguages(queryLanguages), + aroundLatLng: aroundLatLng, + aroundLatLngViaIP: aroundLatLngViaIP, + aroundRadius: aroundRadius, + aroundPrecision: aroundPrecision, + minimumAroundRadius: minimumAroundRadius, + insideBoundingBox: insideBoundingBox, + insidePolygon: insidePolygon, + injectedItems: _toInjectedItems(injectedItems), + ); + } + + /// Build the `facets` parameter by combining plain [facets] with + /// [disjunctiveFacets], annotating disjunctive facets with the + /// `disjunctive(...)` modifier only when they have a selected value. + /// + /// Annotating only selected disjunctive facets keeps the request under the + /// Composition API limit of 20 disjunctive facets. + List? _buildFacets() { + final plain = facets ?? const []; + final disjunctive = disjunctiveFacets ?? const {}; + if (plain.isEmpty && disjunctive.isEmpty) return null; + + final refined = _refinedAttributes(); + final result = {...plain}; + for (final facet in disjunctive) { + result.add(refined.contains(facet) ? 'disjunctive($facet)' : facet); + } + if (result.contains('*')) return ['*']; + final sorted = result.toList()..sort(); + return sorted; + } + + /// Set of facet attributes that have at least one selected facet filter. + Set _refinedAttributes() { + final attributes = {}; + for (final group in filterGroups ?? const {}) { + for (final filter in group) { + if (filter is FilterFacet) { + attributes.add(filter.attribute); + } + } + } + return attributes; + } +} + +extension CompositionFacetSearchStateExt on CompositionFacetSearchState { + /// Build a composition [composition.SearchForFacetValuesRequest]. + composition.SearchForFacetValuesRequest toRequest() => + composition.SearchForFacetValuesRequest( + params: composition.SearchForFacetValuesParams( + query: facetQuery, + searchQuery: state._toParams(), + ), + ); +} + +extension CompositionSearchResponseExt on composition.SearchResponse { + /// Convert a Composition run [composition.SearchResponse] to a helper + /// [CompositionResponse]. When the composition is multifeed, feed results + /// are exposed through [CompositionResponse.feeds] (ordered). + CompositionResponse toCompositionResponse() { + final items = results.map((item) => item.toJson()).toList(); + if (items.isEmpty) { + return CompositionResponse(const {}); + } + final feeds = items + .where((item) => item['feedID'] != null) + .map(CompositionResponse.new) + .toList(); + return CompositionResponse(items.first, feeds: feeds); + } +} + +extension CompositionFacetSearchResponseExt + on composition.SearchForFacetValuesResponse { + /// Convert a composition [composition.SearchForFacetValuesResponse] to a + /// helper [CompositionFacetSearchResponse]. + CompositionFacetSearchResponse toFacetSearchResponse() { + final first = results?.firstOrNull; + return CompositionFacetSearchResponse(first?.toJson() ?? const {}); + } +} + +List? _toLanguages(List? languages) => + languages?.map(composition.SupportedLanguage.fromJson).toList(); + +Map? _toInjectedItems( + Map? items, +) => + items?.map( + (key, value) => MapEntry( + key, + composition.ExternalInjectedItem.fromJson( + Map.from(value as Map), + ), + ), + ); diff --git a/helper/lib/src/service/composition_facet_search_service.dart b/helper/lib/src/service/composition_facet_search_service.dart new file mode 100644 index 0000000..6f6c399 --- /dev/null +++ b/helper/lib/src/service/composition_facet_search_service.dart @@ -0,0 +1,12 @@ +import '../model/composition_facet_search_response.dart'; +import '../model/composition_facet_search_state.dart'; + +/// A contract search Service handling composition facet search requests and +/// responses. +abstract class CompositionFacetSearchService { + /// Send a composition facet search request [state] and asynchronously get a + /// [CompositionFacetSearchResponse]. + Future search( + CompositionFacetSearchState state, + ); +} diff --git a/helper/lib/src/service/composition_search_service.dart b/helper/lib/src/service/composition_search_service.dart new file mode 100644 index 0000000..0c48e07 --- /dev/null +++ b/helper/lib/src/service/composition_search_service.dart @@ -0,0 +1,9 @@ +import '../model/composition_response.dart'; +import '../model/composition_state.dart'; + +/// A contract search Service handling composition run requests and responses. +abstract class CompositionSearchService { + /// Send a composition run request [state] and asynchronously get a + /// [CompositionResponse]. + Future search(CompositionState state); +} diff --git a/helper/pubspec.yaml b/helper/pubspec.yaml index 93399b2..8ae11ad 100644 --- a/helper/pubspec.yaml +++ b/helper/pubspec.yaml @@ -1,12 +1,13 @@ name: algolia_helper_flutter description: Patterns and APIs to implement advanced search features with Algolia for Flutter -version: 1.6.0 +version: 1.7.0 homepage: https://www.algolia.com/doc/guides/building-search-ui/what-is-instantsearch/flutter/ repository: https://github.com/algolia/algoliasearch-helper-flutter/tree/main/helper environment: sdk: ">=3.0.0 <4.0.0" flutter: ">=1.17.0" dependencies: + algolia_client_composition: ">=1.51.0 <2.0.0" algolia_insights: ^1.0.4 algoliasearch: ">=1.10.0 <2.0.0" collection: ">=1.17.0 <2.0.0" diff --git a/helper/test/composition_client_extensions_test.dart b/helper/test/composition_client_extensions_test.dart new file mode 100644 index 0000000..c9650a8 --- /dev/null +++ b/helper/test/composition_client_extensions_test.dart @@ -0,0 +1,231 @@ +import 'package:algolia_client_composition/algolia_client_composition.dart' + as composition; +import 'package:algolia_helper_flutter/src/exception.dart'; +import 'package:algolia_helper_flutter/src/filter.dart'; +import 'package:algolia_helper_flutter/src/filter_group.dart'; +import 'package:algolia_helper_flutter/src/model/composition_facet_search_state.dart'; +import 'package:algolia_helper_flutter/src/model/composition_state.dart'; +import 'package:algolia_helper_flutter/src/service/composition_client_extensions.dart'; +import 'package:test/test.dart'; + +void main() { + group('CompositionState.toRequestBody', () { + test('maps basic params', () { + const state = CompositionState( + compositionID: 'my-composition', + query: 'shoes', + page: 2, + hitsPerPage: 20, + userToken: 'user-1', + clickAnalytics: true, + enablePersonalization: true, + sortBy: 'Price (asc)', + relevancyStrictness: 90, + ); + + final params = state.toRequestBody().params!; + + expect(params.query, 'shoes'); + expect(params.page, 2); + expect(params.hitsPerPage, 20); + expect(params.userToken, 'user-1'); + expect(params.clickAnalytics, true); + expect(params.enablePersonalization, true); + expect(params.sortBy, 'Price (asc)'); + expect(params.relevancyStrictness, 90); + }); + + test('converts filter groups to SQL filters string', () { + final state = CompositionState( + compositionID: 'my-composition', + filterGroups: { + FilterGroup.facet(filters: {Filter.facet('attributeA', 0)}), + FilterGroup.tag( + operator: FilterOperator.or, + filters: {Filter.tag('unknown')}, + ), + }, + ); + + final params = state.toRequestBody().params!; + + expect( + params.filters, + '("attributeA":0) AND (_tags:"unknown")', + ); + }); + + test('annotates disjunctive facets only when a value is selected', () { + final state = CompositionState( + compositionID: 'my-composition', + facets: const ['brand'], + disjunctiveFacets: const {'color', 'size'}, + filterGroups: { + FilterGroup.facet( + name: 'color', + operator: FilterOperator.or, + filters: {Filter.facet('color', 'red')}, + ), + }, + ); + + final params = state.toRequestBody().params!; + + // `color` is refined -> annotated; `size` has no selection -> plain. + expect( + params.facets, + ['brand', 'disjunctive(color)', 'size'], + ); + }); + + test('collapses to wildcard when `*` is present', () { + const state = CompositionState( + compositionID: 'my-composition', + facets: ['*', 'brand'], + ); + + final params = state.toRequestBody().params!; + + expect(params.facets, ['*']); + }); + + test('facets is null when no facets are set', () { + const state = CompositionState(compositionID: 'my-composition'); + final params = state.toRequestBody().params!; + expect(params.facets, isNull); + }); + + test('converts language codes and injected items', () { + const state = CompositionState( + compositionID: 'my-composition', + queryLanguages: ['en', 'fr'], + naturalLanguages: ['de'], + injectedItems: { + 'group-a': { + 'items': [ + {'objectID': 'o1', 'position': 1}, + ], + }, + }, + ); + + final params = state.toRequestBody().params!; + + expect(params.queryLanguages, [ + composition.SupportedLanguage.en, + composition.SupportedLanguage.fr, + ]); + expect(params.naturalLanguages, [composition.SupportedLanguage.de]); + expect(params.injectedItems, isNotNull); + expect(params.injectedItems!['group-a'], isNotNull); + }); + }); + + group('CompositionFacetSearchState.toRequest', () { + test('maps facet query and underlying search params', () { + const state = CompositionFacetSearchState( + compositionID: 'my-composition', + facet: 'brand', + facetQuery: 'sams', + state: CompositionState( + compositionID: 'my-composition', + query: 'phone', + ), + ); + + final request = state.toRequest(); + + expect(request.params?.query, 'sams'); + expect(request.params?.searchQuery?.query, 'phone'); + }); + }); + + group('composition response mapping', () { + test('single result exposes hits and no feeds', () { + final response = composition.SearchResponse( + results: [ + const composition.SearchResultsItem( + compositions: {}, + hits: [composition.Hit(objectID: '1')], + nbHits: 1, + query: 'shoes', + ), + ], + ).toCompositionResponse(); + + expect(response.hits.length, 1); + expect(response.hits.first['objectID'], '1'); + expect(response.nbHits, 1); + expect(response.query, 'shoes'); + expect(response.feeds, isNull); + }); + + test('multifeed results expose ordered feeds', () { + final response = composition.SearchResponse( + results: [ + const composition.SearchResultsItem( + compositions: {}, + hits: [composition.Hit(objectID: '1')], + nbHits: 100, + feedID: 'products', + ), + const composition.SearchResultsItem( + compositions: {}, + hits: [composition.Hit(objectID: '2')], + nbHits: 50, + feedID: 'articles', + ), + ], + ).toCompositionResponse(); + + expect(response.feeds, isNotNull); + expect(response.feeds!.length, 2); + expect(response.feeds!.first.feedID, 'products'); + expect(response.feeds!.first.nbHits, 100); + expect(response.feeds!.last.feedID, 'articles'); + expect(response.feeds!.last.nbHits, 50); + // Primary response points to the first feed. + expect(response.feedID, 'products'); + expect(response.nbHits, 100); + }); + + test('empty results produce an empty response', () { + final response = + composition.SearchResponse(results: const []).toCompositionResponse(); + expect(response.hits, isEmpty); + expect(response.feeds, isNull); + }); + }); + + group('composition facet response mapping', () { + test('maps facet hits from the first result', () { + final response = composition.SearchForFacetValuesResponse( + results: const [ + composition.SearchForFacetValuesResults( + indexName: 'my-index', + exhaustiveFacetsCount: true, + facetHits: [ + composition.FacetHits( + value: 'samsung', + highlighted: 'samsung', + count: 5, + ), + ], + ), + ], + ).toFacetSearchResponse(); + + expect(response.facetHits.length, 1); + expect(response.facetHits.first.value, 'samsung'); + expect(response.facetHits.first.count, 5); + }); + }); + + group('launderCompositionException', () { + test('wraps arbitrary errors into a SearchError', () { + final error = launderCompositionException('boom'); + expect(error, isA()); + expect((error as SearchError).statusCode, 0); + }); + }); +} diff --git a/helper/test/composition_facet_searcher_test.dart b/helper/test/composition_facet_searcher_test.dart new file mode 100644 index 0000000..a8929c0 --- /dev/null +++ b/helper/test/composition_facet_searcher_test.dart @@ -0,0 +1,140 @@ +import 'dart:async'; + +import 'package:algolia_helper_flutter/src/exception.dart'; +import 'package:algolia_helper_flutter/src/model/composition_facet_search_response.dart'; +import 'package:algolia_helper_flutter/src/model/composition_facet_search_state.dart'; +import 'package:algolia_helper_flutter/src/model/composition_state.dart'; +import 'package:algolia_helper_flutter/src/searcher/composition_facet_searcher.dart'; +import 'package:algolia_helper_flutter/src/service/composition_facet_search_service.dart'; +import 'package:test/test.dart'; + +void main() { + group('Unit tests', () { + test('Should emit initial response', () async { + final searchService = _FakeService( + (_) async => CompositionFacetSearchResponse(const { + 'facetHits': [ + {'value': 'v', 'count': 1}, + ], + }), + ); + final searcher = CompositionFacetSearcher.custom( + searchService, + _state(), + ); + + await expectLater( + searcher.responses, + emits(isA()), + ); + }); + + test('Should emit response after query', () async { + final searchService = _FakeService(_mockResponse); + final searcher = CompositionFacetSearcher.custom( + searchService, + _state(facetQuery: 'cat'), + ); + + searcher.query('cat'); + + await expectLater(searcher.responses, emits(matchesQuery('cat'))); + }); + + test('Should emit error after failure', () async { + final searchService = _FakeService((_) => throw SearchError({}, 500)); + final searcher = CompositionFacetSearcher.custom(searchService, _state()); + + searcher.query('cat'); + + await expectLater(searcher.responses, emitsError(isA())); + }); + + test('Should debounce search state', () async { + final searchService = _FakeService(_mockResponse); + final searcher = CompositionFacetSearcher.custom(searchService, _state()); + + unawaited( + expectLater( + searcher.responses, + emitsInOrder([emits(matchesQuery('cat'))]), + ), + ); + + searcher + ..query('c') + ..query('ca') + ..query('cat'); + }); + + test('Should rerun requests', () async { + final searchService = _FakeService(_mockResponse); + final searcher = CompositionFacetSearcher.custom(searchService, _state()); + + unawaited( + expectLater( + searcher.responses, + emitsInOrder([ + emits(matchesQuery('cat')), + emits(matchesQuery('cat')), + ]), + ), + ); + + searcher.query('cat'); + await delay(); + searcher.query('cat'); // should be ignored + await delay(); + searcher.rerun(); + await delay(); + }); + }); +} + +CompositionFacetSearchState _state({String facetQuery = ''}) => + CompositionFacetSearchState( + compositionID: 'myComposition', + facet: 'brand', + facetQuery: facetQuery, + state: const CompositionState(compositionID: 'myComposition'), + ); + +Future _mockResponse( + CompositionFacetSearchState state, +) async { + await delay(100); + return CompositionFacetSearchResponse({ + 'query': state.facetQuery, + 'exhaustiveFacetsCount': true, + 'facetHits': [ + {'value': 'facet1', 'count': 5}, + {'value': 'facet2', 'count': 10}, + ], + }); +} + +/// Return future with a delay +Future delay([int millis = 500]) => + Future.delayed(Duration(milliseconds: millis), () {}); + +/// Matches a [CompositionFacetSearchResponse] with a given [query]. +TypeMatcher matchesQuery(String query) => + isA().having( + (res) => res.raw['query'], + 'query', + matches(query), + ); + +class _FakeService implements CompositionFacetSearchService { + _FakeService(this._handler); + + final FutureOr Function( + CompositionFacetSearchState state, + ) _handler; + + @override + Future search( + CompositionFacetSearchState state, + ) async => + _handler(state); +} diff --git a/helper/test/composition_searcher_test.dart b/helper/test/composition_searcher_test.dart new file mode 100644 index 0000000..115bdba --- /dev/null +++ b/helper/test/composition_searcher_test.dart @@ -0,0 +1,358 @@ +import 'dart:async'; + +import 'package:algolia_helper_flutter/src/exception.dart'; +import 'package:algolia_helper_flutter/src/filter.dart'; +import 'package:algolia_helper_flutter/src/filter_group.dart'; +import 'package:algolia_helper_flutter/src/filter_state.dart'; +import 'package:algolia_helper_flutter/src/model/composition_response.dart'; +import 'package:algolia_helper_flutter/src/model/composition_state.dart'; +import 'package:algolia_helper_flutter/src/searcher/composition_searcher.dart'; +import 'package:algolia_helper_flutter/src/service/composition_search_service.dart'; +import 'package:algolia_insights/algolia_insights.dart'; +import 'package:test/test.dart'; + +void main() { + group('Unit tests', () { + test('Should emit initial response', () async { + final searchService = _FakeCompositionSearchService( + (_) async => CompositionResponse(const {}), + ); + final searcher = CompositionSearcher.custom( + searchService, + null, + const CompositionState(compositionID: 'myComposition'), + ); + + await expectLater( + searcher.responses, + emits(isA()), + ); + }); + + test('Should emit response after query', () async { + final searchService = _FakeCompositionSearchService(_mockResponse); + final searcher = CompositionSearcher.custom( + searchService, + null, + const CompositionState(compositionID: 'myComposition'), + ); + + searcher.query('cat'); + + await expectLater(searcher.responses, emits(matchesQuery('cat'))); + }); + + test('Should emit error after failure', () async { + final searchService = _FakeCompositionSearchService( + (_) => throw SearchError({}, 500), + ); + final searcher = CompositionSearcher.custom( + searchService, + null, + const CompositionState(compositionID: 'myComposition'), + ); + + searcher.query('cat'); + + await expectLater(searcher.responses, emitsError(isA())); + }); + + test('Should debounce search state', () async { + final searchService = _FakeCompositionSearchService(_mockResponse); + final searcher = CompositionSearcher.custom( + searchService, + null, + const CompositionState(compositionID: 'myComposition'), + ); + + unawaited( + expectLater( + searcher.responses, + emitsInOrder([emits(matchesQuery('cat'))]), + ), + ); + + searcher + ..query('c') + ..query('ca') + ..query('cat'); + }); + + test("Shouldn't debounce search state", () async { + final searchService = _FakeCompositionSearchService(_mockResponse); + final searcher = CompositionSearcher.custom( + searchService, + null, + const CompositionState(compositionID: 'myComposition'), + ); + + unawaited( + expectLater( + searcher.responses, + emitsInOrder([ + emits(matchesQuery('c')), + emits(matchesQuery('ca')), + emits(matchesQuery('cat')), + ]), + ), + ); + + searcher.query('c'); + await delay(); + searcher.query('ca'); + await delay(); + searcher.query('cat'); + await delay(); + searcher.dispose(); + }); + + test('Should discard old requests', () async { + final searchService = _FakeCompositionSearchService(_mockResponse); + final searcher = CompositionSearcher.custom( + searchService, + null, + const CompositionState(compositionID: 'myComposition'), + ); + + unawaited( + expectLater( + searcher.responses, + emitsInOrder([emits(matchesQuery('cat'))]), + ), + ); + + searcher.query('c'); + await delay(50); + searcher.query('ca'); + await delay(50); + searcher.query('cat'); + await delay(50); + }); + + test('Should rerun requests', () async { + final searchService = _FakeCompositionSearchService(_mockResponse); + final searcher = CompositionSearcher.custom( + searchService, + null, + const CompositionState(compositionID: 'myComposition'), + ); + + unawaited( + expectLater( + searcher.responses, + emitsInOrder([ + emits(matchesQuery('cat')), + emits(matchesQuery('cat')), + ]), + ), + ); + + searcher.query('cat'); + await delay(); + searcher.query('cat'); // should be ignored + await delay(); + searcher.rerun(); + await delay(); + }); + }); + + test('FilterState connect CompositionSearcher', () async { + final searchService = _FakeCompositionSearchService(_mockResponse); + const initState = CompositionState(compositionID: 'myComposition'); + final searcher = CompositionSearcher.custom(searchService, null, initState); + + final groupColors = FilterGroupID.and('colors'); + final facetColorRed = Filter.facet('color', 'red'); + final filterState = FilterState()..add(groupColors, {facetColorRed}); + + searcher.connectFilterState(filterState); + await delay(); + + final updated = initState.copyWith( + filterGroups: { + FacetFilterGroup(groupColors, {facetColorRed}), + }, + ); + expect(searcher.snapshot(), updated); + + searcher.dispose(); + }); + + group('Insights', () { + test('Passes received hits to event tracker', () async { + final searchService = _FakeCompositionSearchService(_mockResponse); + final eventTracker = _RecordingEventTracker(); + final searcher = CompositionSearcher.custom( + searchService, + eventTracker, + const CompositionState(compositionID: 'myComposition'), + )..query('q'); + + await delay(); + + expect(eventTracker.viewed, isNotEmpty); + expect(eventTracker.viewed.last['eventName'], 'Hits Viewed'); + expect(eventTracker.viewed.last['objectIDs'], ['h1', 'h2']); + + searcher.dispose(); + }); + + test('Uses response index name and queryID for click events', () async { + final searchService = _FakeCompositionSearchService(_mockResponse); + final eventTracker = _RecordingEventTracker(); + final searcher = CompositionSearcher.custom( + searchService, + eventTracker, + const CompositionState(compositionID: 'myComposition'), + )..query('q'); + + await expectLater(searcher.responses, emits(matchesQuery('q'))); + + searcher.eventTracker?.clickedObjects( + eventName: 'Product Clicked', + objectIDs: const ['h1'], + positions: const [1], + ); + + expect(eventTracker.clickedAfterSearch, isNotEmpty); + expect( + eventTracker.clickedAfterSearch.last['indexName'], + 'products-index', + ); + expect(eventTracker.clickedAfterSearch.last['queryID'], '123'); + + searcher.dispose(); + }); + }); +} + +Future _mockResponse(CompositionState state) async { + await delay(100); + return CompositionResponse({ + 'query': state.query, + 'index': 'products-index', + 'hits': [ + {'objectID': 'h1'}, + {'objectID': 'h2'}, + ], + 'queryID': '123', + 'nbHits': 2, + }); +} + +/// Return future with a delay +Future delay([int millis = 500]) => + Future.delayed(Duration(milliseconds: millis), () {}); + +/// Matches a [CompositionResponse] with a given [query]. +TypeMatcher matchesQuery(String query) => + isA().having((res) => res.query, 'query', query); + +class _FakeCompositionSearchService implements CompositionSearchService { + _FakeCompositionSearchService(this._handler); + + final FutureOr Function(CompositionState state) _handler; + + @override + Future search(CompositionState state) async => + _handler(state); +} + +class _RecordingEventTracker implements EventTracker { + final List> viewed = []; + final List> clicked = []; + final List> clickedAfterSearch = []; + + @override + bool get isEnabled => true; + + @override + void viewedObjects({ + required String indexName, + required String eventName, + required List objectIDs, + DateTime? timestamp, + }) { + viewed.add({ + 'indexName': indexName, + 'eventName': eventName, + 'objectIDs': objectIDs, + }); + } + + @override + void clickedObjects({ + required String indexName, + required String eventName, + required Iterable objectIDs, + DateTime? timestamp, + }) { + clicked.add({ + 'indexName': indexName, + 'eventName': eventName, + 'objectIDs': objectIDs.toList(), + }); + } + + @override + void clickedObjectsAfterSearch({ + required String indexName, + required String eventName, + required String queryID, + required Iterable objectIDs, + required Iterable positions, + DateTime? timestamp, + }) { + clickedAfterSearch.add({ + 'indexName': indexName, + 'eventName': eventName, + 'queryID': queryID, + 'objectIDs': objectIDs.toList(), + 'positions': positions.toList(), + }); + } + + @override + void convertedObjects({ + required String indexName, + required String eventName, + required Iterable objectIDs, + DateTime? timestamp, + }) {} + + @override + void convertedObjectsAfterSearch({ + required String indexName, + required String eventName, + required String queryID, + required Iterable objectIDs, + DateTime? timestamp, + }) {} + + @override + void clickedFilters({ + required String indexName, + required String eventName, + required String attribute, + required List values, + DateTime? timestamp, + }) {} + + @override + void viewedFilters({ + required String indexName, + required String eventName, + required String attribute, + required List values, + DateTime? timestamp, + }) {} + + @override + void convertedFilters({ + required String indexName, + required String eventName, + required String attribute, + required List values, + DateTime? timestamp, + }) {} +} diff --git a/insights/CHANGELOG.md b/insights/CHANGELOG.md index 5ca637a..a728cd5 100644 --- a/insights/CHANGELOG.md +++ b/insights/CHANGELOG.md @@ -1,3 +1,7 @@ +## 1.0.5 + + - **FIX**(deps): require `algolia_client_insights >=1.51.0` to pull a `dio`-compatible `algolia_client_core`. + ## 1.0.4 - **FIX**(insights): skip pushing events if empty (#170). diff --git a/insights/lib/src/lib_version.dart b/insights/lib/src/lib_version.dart index e500dea..1ea653f 100644 --- a/insights/lib/src/lib_version.dart +++ b/insights/lib/src/lib_version.dart @@ -1 +1 @@ -const libVersion = '1.0.4'; +const libVersion = '1.0.5'; diff --git a/insights/pubspec.yaml b/insights/pubspec.yaml index fc16840..ea23708 100644 --- a/insights/pubspec.yaml +++ b/insights/pubspec.yaml @@ -1,6 +1,6 @@ name: algolia_insights description: Algolia Insights for Dart provides a simple and flexible way to track events such as clicks, conversions, and views on search results. -version: 1.0.4 +version: 1.0.5 homepage: https://www.algolia.com/doc/guides/building-search-ui/what-is-instantsearch/flutter/ repository: https://github.com/algolia/algoliasearch-helper-flutter/tree/main/insights @@ -8,7 +8,7 @@ environment: sdk: ">=2.17.5 <4.0.0" flutter: '>=1.17.0' dependencies: - algolia_client_insights: ">=1.2.0 <2.0.0" + algolia_client_insights: ">=1.51.0 <2.0.0" collection: ">=1.17.0 <2.0.0" flutter: sdk: flutter