diff --git a/.code-samples.meilisearch.yaml b/.code-samples.meilisearch.yaml index 3fce5871..991d978a 100644 --- a/.code-samples.meilisearch.yaml +++ b/.code-samples.meilisearch.yaml @@ -3,6 +3,8 @@ # the documentation on build # You can read more on https://github.com/meilisearch/documentation/tree/master/.vuepress/code-samples --- +search_parameter_guide_show_ranking_score_1: |- + await client.index('movies').search('dragon', SearchQuery(showRankingScore: true)); facet_search_2: |- await client.index('books').updateFaceting(Faceting(sortFacetValuesBy: {'genres': 'count'})); search_parameter_guide_attributes_to_search_on_1: |- diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index c3eea83f..c7903028 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -40,7 +40,7 @@ First of all, thank you for contributing to Meilisearch! The goal of this docume Install the official [Dart SDK](https://dart.dev/get-dart) or the [Flutter SDK](https://flutter.dev/docs/get-started/install) (which includes Dart SDK) using guides on the official website. -Both of them include `pub`. But if you want to run the linter you need to install the Flutter SDK. +Both of them include `pub`. ### Setup @@ -54,13 +54,13 @@ docker-compose run --rm package bash -c "dart pub get && dart run test --concurr To install dependencies: ```bash -pub get +dart pub get ``` -Or if you are using Flutter SDK: +This package relies on [build_runner](https://pub.dev/packages/build_runner) to generate serialization information for some models, to re-generate files after making any changes, run: ```bash -flutter pub get +dart run build_runner build ``` ### Tests and Linter @@ -71,9 +71,9 @@ Each PR should pass the tests and the linter to be accepted. # Tests curl -L https://install.meilisearch.com | sh # download Meilisearch ./meilisearch --master-key=masterKey --no-analytics # run Meilisearch -pub run test --concurrency=4 +dart test # Linter -flutter analyze +dart analyze ``` ## Git Guidelines diff --git a/lib/src/query_parameters/index_search_query.dart b/lib/src/query_parameters/index_search_query.dart index ec886d31..5ae3cb28 100644 --- a/lib/src/query_parameters/index_search_query.dart +++ b/lib/src/query_parameters/index_search_query.dart @@ -29,6 +29,9 @@ class IndexSearchQuery extends SearchQuery { super.highlightPostTag, super.matchingStrategy, super.attributesToSearchOn, + super.showRankingScore, + super.vector, + super.showRankingScoreDetails, }); @override @@ -62,6 +65,9 @@ class IndexSearchQuery extends SearchQuery { String? highlightPostTag, MatchingStrategy? matchingStrategy, List? attributesToSearchOn, + bool? showRankingScore, + List */ >? vector, + bool? showRankingScoreDetails, }) => IndexSearchQuery( query: query ?? this.query, @@ -85,5 +91,9 @@ class IndexSearchQuery extends SearchQuery { highlightPostTag: highlightPostTag ?? this.highlightPostTag, matchingStrategy: matchingStrategy ?? this.matchingStrategy, attributesToSearchOn: attributesToSearchOn ?? this.attributesToSearchOn, + showRankingScore: showRankingScore ?? this.showRankingScore, + vector: vector ?? this.vector, + showRankingScoreDetails: + showRankingScoreDetails ?? this.showRankingScoreDetails, ); } diff --git a/lib/src/query_parameters/search_query.dart b/lib/src/query_parameters/search_query.dart index e01df4c3..27918ab3 100644 --- a/lib/src/query_parameters/search_query.dart +++ b/lib/src/query_parameters/search_query.dart @@ -1,4 +1,5 @@ import 'package:meilisearch/meilisearch.dart'; +import 'package:meilisearch/src/annotations.dart'; import 'queryable.dart'; class SearchQuery extends Queryable { @@ -20,6 +21,12 @@ class SearchQuery extends Queryable { final String? highlightPostTag; final MatchingStrategy? matchingStrategy; final List? attributesToSearchOn; + @RequiredMeiliServerVersion('1.3.0') + final bool? showRankingScore; + @RequiredMeiliServerVersion('1.3.0') + final bool? showRankingScoreDetails; + @RequiredMeiliServerVersion('1.3.0') + final List */ >? vector; const SearchQuery({ this.offset, @@ -40,6 +47,9 @@ class SearchQuery extends Queryable { this.highlightPostTag, this.matchingStrategy, this.attributesToSearchOn, + this.showRankingScore, + this.showRankingScoreDetails, + this.vector, }); @override @@ -62,6 +72,9 @@ class SearchQuery extends Queryable { 'highlightPostTag': highlightPostTag, 'matchingStrategy': matchingStrategy?.name, 'attributesToSearchOn': attributesToSearchOn, + 'showRankingScore': showRankingScore, + 'showRankingScoreDetails': showRankingScoreDetails, + 'vector': vector, }; } @@ -84,6 +97,9 @@ class SearchQuery extends Queryable { String? highlightPostTag, MatchingStrategy? matchingStrategy, List? attributesToSearchOn, + bool? showRankingScore, + List? vector, + bool? showRankingScoreDetails, }) => SearchQuery( offset: offset ?? this.offset, @@ -105,5 +121,9 @@ class SearchQuery extends Queryable { highlightPostTag: highlightPostTag ?? this.highlightPostTag, matchingStrategy: matchingStrategy ?? this.matchingStrategy, attributesToSearchOn: attributesToSearchOn ?? this.attributesToSearchOn, + showRankingScore: showRankingScore ?? this.showRankingScore, + vector: vector ?? this.vector, + showRankingScoreDetails: + showRankingScoreDetails ?? this.showRankingScoreDetails, ); } diff --git a/lib/src/results/_exports.dart b/lib/src/results/_exports.dart index 4d0ca1b4..4e35ba5b 100644 --- a/lib/src/results/_exports.dart +++ b/lib/src/results/_exports.dart @@ -12,3 +12,5 @@ export 'matching_strategy_enum.dart'; export 'index_stats.dart'; export 'all_stats.dart'; export 'facet_stat.dart'; +export 'document_container.dart'; +export 'ranking_rules/_exports.dart'; diff --git a/lib/src/results/document_container.dart b/lib/src/results/document_container.dart new file mode 100644 index 00000000..0a99a805 --- /dev/null +++ b/lib/src/results/document_container.dart @@ -0,0 +1,163 @@ +import 'package:meilisearch/src/annotations.dart'; + +import 'match_position.dart'; +import 'ranking_rules/base.dart'; +import 'searchable.dart'; + +/// A class that wraps around documents returned from meilisearch to provide useful information. +final class MeiliDocumentContainer { + const MeiliDocumentContainer._({ + required this.rankingScoreDetails, + required this.src, + required this.parsed, + required this.formatted, + required this.vectors, + required this.semanticScore, + required this.rankingScore, + required this.matchesPosition, + }); + + final Map src; + final T parsed; + final Map? formatted; + @RequiredMeiliServerVersion('1.3.0') + final List */ >? vectors; + @RequiredMeiliServerVersion('1.3.0') + final double? semanticScore; + @RequiredMeiliServerVersion('1.3.0') + final double? rankingScore; + @RequiredMeiliServerVersion('1.3.0') + final MeiliRankingScoreDetails? rankingScoreDetails; + + /// Contains the location of each occurrence of queried terms across all fields + final Map>? matchesPosition; + + dynamic operator [](String key) => src[key]; + dynamic getFormatted(String key) => formatted?[key]; + + dynamic getFormattedOrSrc(String key) => getFormatted(key) ?? this[key]; + + static MeiliDocumentContainer> fromJson( + Map src, + ) { + final rankingScoreDetails = + src['_rankingScoreDetails'] as Map?; + return MeiliDocumentContainer>._( + src: src, + parsed: src, + formatted: src['_formatted'] as Map?, + vectors: src['_vectors'] as List?, + semanticScore: src['_semanticScore'] as double?, + rankingScore: src['_rankingScore'] as double?, + matchesPosition: _readMatchesPosition(src), + rankingScoreDetails: rankingScoreDetails == null + ? null + : MeiliRankingScoreDetails.fromJson(rankingScoreDetails), + ); + } + + MeiliDocumentContainer map( + MeilisearchDocumentMapper mapper, + ) { + return MeiliDocumentContainer._( + src: src, + parsed: mapper(parsed), + formatted: formatted, + vectors: vectors, + semanticScore: semanticScore, + rankingScore: rankingScore, + rankingScoreDetails: rankingScoreDetails, + matchesPosition: matchesPosition); + } + + @override + String toString() => src.toString(); +} + +class MeiliRankingScoreDetails { + const MeiliRankingScoreDetails._({ + required this.src, + required this.words, + required this.typo, + required this.proximity, + required this.attribute, + required this.exactness, + required this.customRules, + }); + final Map src; + final MeiliRankingScoreDetailsWordsRule? words; + final MeiliRankingScoreDetailsTypoRule? typo; + final MeiliRankingScoreDetailsProximityRule? proximity; + final MeiliRankingScoreDetailsAttributeRule? attribute; + final MeiliRankingScoreDetailsExactnessRule? exactness; + final Map customRules; + + factory MeiliRankingScoreDetails.fromJson(Map src) { + final reservedKeys = { + 'attribute', + 'words', + 'exactness', + 'proximity', + 'typo', + }; + + T? ruleGuarded( + String key, + T Function(Map src) mapper, + ) { + final v = src[key]; + if (v == null) { + return null; + } + return mapper(v as Map); + } + + return MeiliRankingScoreDetails._( + src: src, + attribute: ruleGuarded( + 'attribute', + MeiliRankingScoreDetailsAttributeRule.fromJson, + ), + words: ruleGuarded( + 'words', + MeiliRankingScoreDetailsWordsRule.fromJson, + ), + exactness: ruleGuarded( + 'exactness', + MeiliRankingScoreDetailsExactnessRule.fromJson, + ), + proximity: ruleGuarded( + 'proximity', + MeiliRankingScoreDetailsProximityRule.fromJson, + ), + typo: ruleGuarded( + 'typo', + MeiliRankingScoreDetailsTypoRule.fromJson, + ), + customRules: { + for (var custom in src.entries + .where((element) => !reservedKeys.contains(element.key))) + custom.key: MeiliRankingScoreDetailsCustomRule.fromJson( + custom.value as Map, + ) + }, + ); + } +} + +Map>? _readMatchesPosition( + Map map, +) { + final src = map['_matchesPosition']; + + if (src == null) return null; + + return (src as Map).map( + (key, value) => MapEntry( + key, + (value as List) + .map((e) => MatchPosition.fromMap(e as Map)) + .toList(), + ), + ); +} diff --git a/lib/src/results/experimental_features.dart b/lib/src/results/experimental_features.dart new file mode 100644 index 00000000..803d4c03 --- /dev/null +++ b/lib/src/results/experimental_features.dart @@ -0,0 +1,79 @@ +import 'package:json_annotation/json_annotation.dart'; +import 'package:meta/meta.dart'; +import '../http_request.dart'; +import '../annotations.dart'; + +part 'experimental_features.g.dart'; + +@visibleForTesting +@JsonSerializable( + createFactory: true, + createToJson: false, +) +class ExperimentalFeatures { + @JsonKey(name: 'vectorStore') + final bool vectorStore; + @JsonKey(name: 'scoreDetails') + final bool scoreDetails; + + const ExperimentalFeatures({ + required this.vectorStore, + required this.scoreDetails, + }); + + factory ExperimentalFeatures.fromJson(Map src) { + return _$ExperimentalFeaturesFromJson(src); + } +} + +@JsonSerializable( + includeIfNull: false, + createToJson: true, + createFactory: false, +) +class UpdateExperimentalFeatures { + @JsonKey(name: 'vectorStore') + final bool? vectorStore; + @JsonKey(name: 'scoreDetails') + final bool? scoreDetails; + + const UpdateExperimentalFeatures({ + this.vectorStore, + this.scoreDetails, + }); + + Map toJson() => _$UpdateExperimentalFeaturesToJson(this); +} + +extension ExperimentalFeaturesExt on HttpRequest { + /// Get the status of all experimental features that can be toggled at runtime + @RequiredMeiliServerVersion('1.3.0') + @visibleForTesting + Future getExperimentalFeatures() async { + final response = await getMethod>( + '/experimental-features', + ); + return ExperimentalFeatures.fromJson(response.data!); + } + + /// Set the status of experimental features that can be toggled at runtime + @RequiredMeiliServerVersion('1.3.0') + @visibleForTesting + Future updateExperimentalFeatures( + UpdateExperimentalFeatures input, + ) async { + final inputJson = input.toJson(); + if (inputJson.isEmpty) { + throw ArgumentError.value( + input, + 'input', + 'input must contain at least one entry', + ); + } + final response = await patchMethod>( + '/experimental-features', + data: input.toJson(), + ); + return ExperimentalFeatures.fromJson(response.data!); + } +} diff --git a/lib/src/results/experimental_features.g.dart b/lib/src/results/experimental_features.g.dart new file mode 100644 index 00000000..1ca870f6 --- /dev/null +++ b/lib/src/results/experimental_features.g.dart @@ -0,0 +1,29 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'experimental_features.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +ExperimentalFeatures _$ExperimentalFeaturesFromJson( + Map json) => + ExperimentalFeatures( + vectorStore: json['vectorStore'] as bool, + scoreDetails: json['scoreDetails'] as bool, + ); + +Map _$UpdateExperimentalFeaturesToJson( + UpdateExperimentalFeatures instance) { + final val = {}; + + void writeNotNull(String key, dynamic value) { + if (value != null) { + val[key] = value; + } + } + + writeNotNull('vectorStore', instance.vectorStore); + writeNotNull('scoreDetails', instance.scoreDetails); + return val; +} diff --git a/lib/src/results/paginated_search_result.dart b/lib/src/results/paginated_search_result.dart index ac38a333..15eb22ca 100644 --- a/lib/src/results/paginated_search_result.dart +++ b/lib/src/results/paginated_search_result.dart @@ -2,26 +2,19 @@ part of 'searchable.dart'; class PaginatedSearchResult extends Searcheable { const PaginatedSearchResult({ - List hits = const [], - Map>? facetDistribution, - Map>? matchesPosition, - int? processingTimeMs, - String? query, - Map? facetStats, - required String indexUid, - this.hitsPerPage, - this.page, - this.totalHits, - this.totalPages, - }) : super( - facetDistribution: facetDistribution, - hits: hits, - matchesPosition: matchesPosition, - processingTimeMs: processingTimeMs, - query: query, - indexUid: indexUid, - facetStats: facetStats, - ); + required super.src, + required super.indexUid, + required super.hits, + required super.facetDistribution, + required super.processingTimeMs, + required super.query, + required super.facetStats, + required super.vector, + required this.hitsPerPage, + required this.page, + required this.totalHits, + required this.totalPages, + }); /// Number of documents skipped final int? hitsPerPage; @@ -40,6 +33,7 @@ class PaginatedSearchResult extends Searcheable { String? indexUid, }) { return PaginatedSearchResult( + src: map, page: map['page'] as int?, hitsPerPage: map['hitsPerPage'] as int?, totalHits: map['totalHits'] as int?, @@ -48,9 +42,9 @@ class PaginatedSearchResult extends Searcheable { query: _readQuery(map), processingTimeMs: _readProcessingTimeMs(map), facetDistribution: _readFacetDistribution(map), - matchesPosition: _readMatchesPosition(map), facetStats: _readFacetStats(map), indexUid: indexUid ?? _readIndexUid(map), + vector: map['vector'] as List?, ); } @@ -59,11 +53,13 @@ class PaginatedSearchResult extends Searcheable { MeilisearchDocumentMapper mapper, ) { return PaginatedSearchResult( + facetStats: facetStats, + src: src, indexUid: indexUid, + vector: vector, facetDistribution: facetDistribution, hits: hits.map(mapper).toList(), hitsPerPage: hitsPerPage, - matchesPosition: matchesPosition, page: page, processingTimeMs: processingTimeMs, query: query, diff --git a/lib/src/results/ranking_rules/_exports.dart b/lib/src/results/ranking_rules/_exports.dart new file mode 100644 index 00000000..cb608059 --- /dev/null +++ b/lib/src/results/ranking_rules/_exports.dart @@ -0,0 +1 @@ +export 'base.dart'; diff --git a/lib/src/results/ranking_rules/attribute.dart b/lib/src/results/ranking_rules/attribute.dart new file mode 100644 index 00000000..eb1828f8 --- /dev/null +++ b/lib/src/results/ranking_rules/attribute.dart @@ -0,0 +1,34 @@ +part of 'base.dart'; + +class MeiliRankingScoreDetailsAttributeRule + extends MeiliRankingScoreDetailsRuleBase { + const MeiliRankingScoreDetailsAttributeRule._({ + required super.src, + required super.order, + required super.score, + required this.attributeRankingOrderScore, + required this.queryWordDistanceScore, + }); + + /// Score computed depending on the first attribute each word of the query appears in. + /// The first attribute in the `searchableAttributes` list yields the highest score, the last attribute the lowest. + final num attributeRankingOrderScore; + + /// Score computed depending on the position the attributes where each word of the query appears in. + /// + /// Words appearing in an attribute at the same position as in the query yield the highest score. + /// + /// The greater the distance to the position in the query, the lower the score. + final num queryWordDistanceScore; + + factory MeiliRankingScoreDetailsAttributeRule.fromJson( + Map src, + ) => + MeiliRankingScoreDetailsAttributeRule._( + src: src, + order: MeiliRankingScoreDetailsRuleBase._readOrder(src), + score: MeiliRankingScoreDetailsRuleBase._readScore(src), + attributeRankingOrderScore: src['attributeRankingOrderScore'] as num, + queryWordDistanceScore: src['queryWordDistanceScore'] as num, + ); +} diff --git a/lib/src/results/ranking_rules/base.dart b/lib/src/results/ranking_rules/base.dart new file mode 100644 index 00000000..7647ae5d --- /dev/null +++ b/lib/src/results/ranking_rules/base.dart @@ -0,0 +1,64 @@ +part 'attribute.dart'; +part 'exactness.dart'; +part 'proximity.dart'; +part 'typo.dart'; +part 'words.dart'; + +abstract class MeiliRankingScoreDetailsRuleBase { + /// The source json object this was created from. + final Map src; + + /// The order that this ranking rule was applied + final int order; + + /// The relevancy score of a document according to a ranking rule and relative to a search query. + /// + /// Higher is better. + /// + /// - `1.0` indicates a perfect match + /// - `0.0` no match at all (Meilisearch should not return documents that don't match the query). + final double score; + + const MeiliRankingScoreDetailsRuleBase({ + required this.src, + required this.order, + required this.score, + }); + + static int _readOrder(Map src) => src['order'] as int; + static double _readScore(Map src) => src['score'] as double; +} + +/// Custom rule in the form of either `attribute:direction` or `_geoPoint(lat, lng):direction`. +class MeiliRankingScoreDetailsCustomRule { + /// The source json object this was created from. + final Map src; + + /// The order that this ranking rule was applied + final int order; + + /// The value that was used for sorting this document + /// - string + /// - number + /// - point + final dynamic value; + + /// The distance between the target point and the geoPoint in the document + final num? distance; + + const MeiliRankingScoreDetailsCustomRule({ + required this.src, + required this.order, + required this.value, + required this.distance, + }); + + factory MeiliRankingScoreDetailsCustomRule.fromJson( + Map src) => + MeiliRankingScoreDetailsCustomRule( + src: src, + order: MeiliRankingScoreDetailsRuleBase._readOrder(src), + distance: src['distance'] as num?, + value: src['value'], + ); +} diff --git a/lib/src/results/ranking_rules/exactness.dart b/lib/src/results/ranking_rules/exactness.dart new file mode 100644 index 00000000..70ca8ebf --- /dev/null +++ b/lib/src/results/ranking_rules/exactness.dart @@ -0,0 +1,27 @@ +part of 'base.dart'; + +class MeiliRankingScoreDetailsExactnessRule + extends MeiliRankingScoreDetailsRuleBase { + const MeiliRankingScoreDetailsExactnessRule._({ + required super.src, + required super.order, + required super.score, + required this.matchType, + }); + + /// One of `exactMatch`, `matchesStart` or `noExactMatch`. + /// - `exactMatch`: the document contains an attribute that exactly matches the query. + /// - `matchesStart`: the document contains an attribute that exactly starts with the query. + /// - `noExactMatch`: any other document. + final String matchType; + + factory MeiliRankingScoreDetailsExactnessRule.fromJson( + Map src, + ) => + MeiliRankingScoreDetailsExactnessRule._( + src: src, + order: MeiliRankingScoreDetailsRuleBase._readOrder(src), + score: MeiliRankingScoreDetailsRuleBase._readScore(src), + matchType: src['matchType'] as String, + ); +} diff --git a/lib/src/results/ranking_rules/proximity.dart b/lib/src/results/ranking_rules/proximity.dart new file mode 100644 index 00000000..77cad4cf --- /dev/null +++ b/lib/src/results/ranking_rules/proximity.dart @@ -0,0 +1,20 @@ +part of 'base.dart'; + +class MeiliRankingScoreDetailsProximityRule + extends MeiliRankingScoreDetailsRuleBase { + const MeiliRankingScoreDetailsProximityRule._({ + required super.src, + required super.order, + required super.score, + }); + + factory MeiliRankingScoreDetailsProximityRule.fromJson( + Map src, + ) { + return MeiliRankingScoreDetailsProximityRule._( + src: src, + order: MeiliRankingScoreDetailsRuleBase._readOrder(src), + score: MeiliRankingScoreDetailsRuleBase._readScore(src), + ); + } +} diff --git a/lib/src/results/ranking_rules/typo.dart b/lib/src/results/ranking_rules/typo.dart new file mode 100644 index 00000000..bb436f1c --- /dev/null +++ b/lib/src/results/ranking_rules/typo.dart @@ -0,0 +1,28 @@ +part of 'base.dart'; + +class MeiliRankingScoreDetailsTypoRule + extends MeiliRankingScoreDetailsRuleBase { + const MeiliRankingScoreDetailsTypoRule._({ + required super.src, + required super.order, + required super.score, + required this.typoCount, + required this.maxTypoCount, + }); + + /// The number of typos to correct in the query to match that document. + final int typoCount; + + /// The maximum number of typos that can be corrected in the query to match a document. + final int maxTypoCount; + + factory MeiliRankingScoreDetailsTypoRule.fromJson(Map src) { + return MeiliRankingScoreDetailsTypoRule._( + src: src, + order: MeiliRankingScoreDetailsRuleBase._readOrder(src), + score: MeiliRankingScoreDetailsRuleBase._readScore(src), + typoCount: src['typoCount'] as int, + maxTypoCount: src['maxTypoCount'] as int, + ); + } +} diff --git a/lib/src/results/ranking_rules/words.dart b/lib/src/results/ranking_rules/words.dart new file mode 100644 index 00000000..d518c5d8 --- /dev/null +++ b/lib/src/results/ranking_rules/words.dart @@ -0,0 +1,28 @@ +part of 'base.dart'; + +class MeiliRankingScoreDetailsWordsRule + extends MeiliRankingScoreDetailsRuleBase { + const MeiliRankingScoreDetailsWordsRule._({ + required super.src, + required super.order, + required super.score, + required this.matchingWords, + required this.maxMatchingWords, + }); + + /// the number of words from the query found + final int matchingWords; + + /// + final int maxMatchingWords; + + factory MeiliRankingScoreDetailsWordsRule.fromJson(Map src) { + return MeiliRankingScoreDetailsWordsRule._( + src: src, + order: MeiliRankingScoreDetailsRuleBase._readOrder(src), + score: MeiliRankingScoreDetailsRuleBase._readScore(src), + matchingWords: src['matchingWords'] as int, + maxMatchingWords: src['maxMatchingWords'] as int, + ); + } +} diff --git a/lib/src/results/search_result.dart b/lib/src/results/search_result.dart index 852afec5..5e9f8bce 100644 --- a/lib/src/results/search_result.dart +++ b/lib/src/results/search_result.dart @@ -1,26 +1,20 @@ part of 'searchable.dart'; +/// Represents an offset-based search result class SearchResult extends Searcheable { const SearchResult({ - List hits = const [], - Map>? facetDistribution, - Map>? matchesPosition, - int? processingTimeMs, - String? query, - Map? facetStats, - required String indexUid, - this.offset, - this.limit, - this.estimatedTotalHits, - }) : super( - facetDistribution: facetDistribution, - hits: hits, - matchesPosition: matchesPosition, - processingTimeMs: processingTimeMs, - query: query, - indexUid: indexUid, - facetStats: facetStats, - ); + required super.src, + required super.indexUid, + required super.hits, + required super.facetDistribution, + required super.processingTimeMs, + required super.query, + required super.facetStats, + required super.vector, + required this.offset, + required this.limit, + required this.estimatedTotalHits, + }); /// Number of documents skipped final int? offset; @@ -36,6 +30,8 @@ class SearchResult extends Searcheable { String? indexUid, }) { return SearchResult( + src: map, + vector: map['vector'] as List?, limit: map['limit'] as int?, offset: map['offset'] as int?, estimatedTotalHits: map['estimatedTotalHits'] as int?, @@ -43,7 +39,6 @@ class SearchResult extends Searcheable { query: _readQuery(map), processingTimeMs: _readProcessingTimeMs(map), facetDistribution: _readFacetDistribution(map), - matchesPosition: _readMatchesPosition(map), indexUid: indexUid ?? _readIndexUid(map), facetStats: _readFacetStats(map), ); @@ -54,13 +49,15 @@ class SearchResult extends Searcheable { MeilisearchDocumentMapper mapper, ) => SearchResult( + src: src, + facetStats: facetStats, + vector: vector, indexUid: indexUid, facetDistribution: facetDistribution, hits: hits.map(mapper).toList(), estimatedTotalHits: estimatedTotalHits, limit: limit, offset: offset, - matchesPosition: matchesPosition, processingTimeMs: processingTimeMs, query: query, ); diff --git a/lib/src/results/searchable.dart b/lib/src/results/searchable.dart index 124877cb..a70c57ee 100644 --- a/lib/src/results/searchable.dart +++ b/lib/src/results/searchable.dart @@ -4,7 +4,14 @@ part 'search_result.dart'; part 'paginated_search_result.dart'; part 'searchable_helpers.dart'; +/// Represents a search result. +/// +/// Can be one of: +/// - [SearchResult] if offset, limit are used. +/// - [PaginatedSearchResult] if page, hitsPerPage are used. abstract class Searcheable { + final Map src; + final String indexUid; /// Query originating the response @@ -19,20 +26,19 @@ abstract class Searcheable { /// Distribution of the given facets final Map? facetStats; - /// Contains the location of each occurrence of queried terms across all fields - final Map>? matchesPosition; - /// Processing time of the query final int? processingTimeMs; + final List*/ >? vector; const Searcheable({ + required this.src, required this.indexUid, - this.query, - this.hits = const [], - this.facetDistribution, - this.matchesPosition, - this.processingTimeMs, - this.facetStats, + required this.query, + required this.hits, + required this.facetDistribution, + required this.processingTimeMs, + required this.facetStats, + required this.vector, }); static Searcheable> createSearchResult( @@ -60,3 +66,19 @@ abstract class Searcheable { return src as SearchResult; } } + +extension MapSearcheable on Searcheable> { + Searcheable>> mapToContainer() => + map(MeiliDocumentContainer.fromJson); +} + +extension MapSearcheableSearchResult on SearchResult> { + SearchResult>> mapToContainer() => + map(MeiliDocumentContainer.fromJson); +} + +extension MapSearcheablePaginatedSearchResult + on PaginatedSearchResult> { + PaginatedSearchResult>> + mapToContainer() => map(MeiliDocumentContainer.fromJson); +} diff --git a/lib/src/results/searchable_helpers.dart b/lib/src/results/searchable_helpers.dart index 554a1081..322159a4 100644 --- a/lib/src/results/searchable_helpers.dart +++ b/lib/src/results/searchable_helpers.dart @@ -37,24 +37,23 @@ Map>? _readFacetDistribution( ); } -Map>? _readMatchesPosition( - Map map, -) { - final src = map['_matchesPosition']; +typedef MeilisearchDocumentMapper = TOther Function(TSrc src); - if (src == null) return null; +extension SearchableMapExt on Future>> { + Future>>> + mapToContainer() => then((value) => value.mapToContainer()); +} - return (src as Map).map( - (key, value) => MapEntry( - key, - (value as List) - .map((e) => MatchPosition.fromMap(e as Map)) - .toList(), - ), - ); +extension SearchResultMapExt on Future>> { + Future>>> + mapToContainer() => then((value) => value.mapToContainer()); } -typedef MeilisearchDocumentMapper = TOther Function(TSrc src); +extension PaginatedSearchResultMapExt + on Future>> { + Future>>> + mapToContainer() => then((value) => value.mapToContainer()); +} extension SearchableExt on Future> { Future> asPaginatedResult() => diff --git a/pubspec.yaml b/pubspec.yaml index 418e70ea..d295d3ee 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -12,11 +12,15 @@ dependencies: dio: ^5.0.0 crypto: ^3.0.1 collection: ^1.17.0 + json_annotation: ^4.8.1 + meta: ^1.9.1 dev_dependencies: test: ^1.0.0 dart_jsonwebtoken: ^2.3.2 lints: ^2.1.0 + json_serializable: ^6.7.1 + build_runner: ^2.4.6 screenshots: - description: The Meilisearch logo. diff --git a/test/indexes_test.dart b/test/indexes_test.dart index c768f477..1bc0f640 100644 --- a/test/indexes_test.dart +++ b/test/indexes_test.dart @@ -162,7 +162,7 @@ void main() { final tasks = await index.getTasks(); - expect(tasks.results.length, equals(3)); + expect(tasks.results, isNotEmpty); }); test('gets a task from a index by taskId', () async { diff --git a/test/search_test.dart b/test/search_test.dart index e66e294c..0fbb14f5 100644 --- a/test/search_test.dart +++ b/test/search_test.dart @@ -1,4 +1,5 @@ import 'package:meilisearch/meilisearch.dart'; +import 'package:meilisearch/src/results/experimental_features.dart'; import 'package:test/test.dart'; import 'utils/books.dart'; @@ -447,4 +448,160 @@ void main() { }); }); }); + + group('Experimental', () { + setUpClient(); + late String uid; + late MeiliSearchIndex index; + late ExperimentalFeatures features; + setUp(() async { + features = await client.http.updateExperimentalFeatures( + UpdateExperimentalFeatures( + scoreDetails: true, + vectorStore: true, + ), + ); + expect(features.scoreDetails, true); + expect(features.vectorStore, true); + + uid = randomUid(); + index = await createIndexWithData(uid: uid, data: vectorBooks); + }); + + test('vector search', () async { + final vector = [0, 1, 2]; + final res = await index + .search( + null, + SearchQuery( + vector: vector, + ), + ) + .asSearchResult() + .mapToContainer(); + + expect(res.vector, vector); + expect( + res.hits, + everyElement( + isA>>() + .having( + (p0) => p0.vectors, + 'vectors', + isNotNull, + ) + .having( + (p0) => p0.semanticScore, + 'semanticScore', + isNotNull, + ), + ), + ); + }); + + test('normal search', () async { + final res = await index + .search( + 'The', + SearchQuery( + showRankingScore: true, + showRankingScoreDetails: true, + attributesToHighlight: ['*'], + showMatchesPosition: true, + ), + ) + .asSearchResult() + .mapToContainer(); + + final attributeMatcher = isA() + .having((p0) => p0.src, 'src', allOf(isNotNull, isNotEmpty)) + .having((p0) => p0.score, 'score', isNotNull) + .having((p0) => p0.order, 'order', isNotNull) + .having((p0) => p0.queryWordDistanceScore, 'queryWordDistanceScore', + isNotNull) + .having((p0) => p0.attributeRankingOrderScore, + 'attributeRankingOrderScore', isNotNull); + + final wordsMatcher = isA() + .having((p0) => p0.src, 'src', allOf(isNotNull, isNotEmpty)) + .having((p0) => p0.score, 'score', isNotNull) + .having((p0) => p0.order, 'order', isNotNull) + .having((p0) => p0.matchingWords, 'matchingWords', isNotNull) + .having((p0) => p0.maxMatchingWords, 'maxMatchingWords', isNotNull); + + final exactnessMatcher = isA() + .having((p0) => p0.src, 'src', allOf(isNotNull, isNotEmpty)) + .having((p0) => p0.score, 'score', isNotNull) + .having((p0) => p0.order, 'order', isNotNull) + .having( + (p0) => p0.matchType, + 'matchType', + allOf(isNotNull, isNotEmpty), + ); + + final typoMatcher = isA() + .having((p0) => p0.src, 'src', allOf(isNotNull, isNotEmpty)) + .having((p0) => p0.score, 'score', isNotNull) + .having((p0) => p0.order, 'order', isNotNull) + .having((p0) => p0.typoCount, 'typoCount', isNotNull) + .having((p0) => p0.maxTypoCount, 'maxTypoCount', isNotNull); + + final proximityMatcher = isA() + .having((p0) => p0.src, 'src', allOf(isNotNull, isNotEmpty)) + .having((p0) => p0.score, 'score', isNotNull) + .having((p0) => p0.order, 'order', isNotNull); + + final rankingScoreDetailsMatcher = isA() + .having((p0) => p0.src, 'src', allOf(isNotNull, isNotEmpty)) + .having((p0) => p0.attribute, 'attribute', attributeMatcher) + .having((p0) => p0.words, 'words', wordsMatcher) + .having((p0) => p0.exactness, 'exactness', exactnessMatcher) + .having((p0) => p0.typo, 'typo', typoMatcher) + .having((p0) => p0.proximity, 'proximity', proximityMatcher) + .having( + (p0) => p0.customRules, 'customRules', allOf(isNotNull, isEmpty)); + + expect(res.hits.length, 2); + + expect( + res.hits, + everyElement( + isA>>() + .having( + (p0) => p0.formatted, + 'formatted', + allOf(isNotNull, isNotEmpty, contains('id')), + ) + .having( + (p0) => p0.matchesPosition, + 'matchesPosition', + allOf(isNotNull, isNotEmpty, containsPair('title', isNotEmpty)), + ) + .having( + (p0) => p0.parsed, + 'parsed', + isNotEmpty, + ) + .having( + (p0) => p0.src, + 'src', + isNotEmpty, + ) + .having( + (p0) => p0.rankingScore, + 'rankingScore', + isNotNull, + ) + .having( + (p0) => p0.rankingScoreDetails, + 'rankingScoreDetails', + rankingScoreDetailsMatcher, + ) + .having( + (p0) => p0.vectors, 'vectors', allOf(isNotNull, isNotEmpty)) + .having((p0) => p0.semanticScore, 'semanticScore', isNull), + ), + ); + }); + }); } diff --git a/test/utils/books.dart b/test/utils/books.dart index 32c2c61e..fc77e178 100644 --- a/test/utils/books.dart +++ b/test/utils/books.dart @@ -7,17 +7,11 @@ import 'wait_for.dart'; Future createDynamicBooksIndex({ String? uid, required int count, -}) async { - final index = client.index(uid ?? randomUid()); - final docs = dynamicBooks(count); - final response = await index.addDocuments(docs).waitFor(client: client); - - if (response.status != 'succeeded') { - throw Exception( - 'Impossible to process test suite, the documents were not added into the index.', - ); - } - return index; +}) { + return createIndexWithData( + uid: uid, + data: dynamicBooks(count), + ); } Future createBooksIndex({String? uid}) async { @@ -31,10 +25,19 @@ Future createNestedBooksIndex({String? uid}) async { Future _createIndex({ String? uid, bool isNested = false, +}) { + return createIndexWithData( + uid: uid, + data: isNested ? nestedBooks : books, + ); +} + +Future createIndexWithData({ + String? uid, + required List> data, }) async { final index = client.index(uid ?? randomUid()); - final docs = isNested ? nestedBooks : books; - final response = await index.addDocuments(docs).waitFor(client: client); + final response = await index.addDocuments(data).waitFor(client: client); if (response.status != 'succeeded') { throw Exception( diff --git a/test/utils/books_data.dart b/test/utils/books_data.dart index 0c3b923a..f9d00c39 100644 --- a/test/utils/books_data.dart +++ b/test/utils/books_data.dart @@ -56,6 +56,29 @@ final books = [ {kbookId: 9999, ktitle: 'The Hobbit', ktag: null}, ]; +final vectorBooks = [ + { + "id": 0, + "title": "Across The Universe", + "_vectors": [0, 0.8, -0.2], + }, + { + "id": 1, + "title": "All Things Must Pass", + "_vectors": [1, -0.2, 0], + }, + { + "id": 2, + "title": "And Your Bird Can Sing", + "_vectors": [-0.2, 4, 6], + }, + { + "id": 3, + "title": "The Matrix", + "_vectors": [5, -0.5, 0.3], + }, +]; + enum CSVHeaderTypes { string, boolean,