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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
141 changes: 79 additions & 62 deletions app/lib/search/mem_index.dart
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,6 @@ import 'package:meta/meta.dart';
import 'package:pub_dev/service/topics/models.dart';
import 'package:pub_dev/third_party/bit_array/bit_array.dart';

import '../shared/utils.dart' show boundedList;
import 'models.dart';
import 'search_service.dart';
import 'text_utils.dart';
Expand Down Expand Up @@ -142,9 +141,9 @@ class InMemoryPackageIndex {
return PackageSearchResult.empty();
}
return _bitArrayPool.withPoolItem(fn: (array) {
return _scorePool.withPoolItem(
fn: (score) {
return _search(query, array, score);
return _scorePool.withItemGetter(
(scoreFn) {
return _search(query, array, scoreFn);
},
);
});
Expand Down Expand Up @@ -220,88 +219,107 @@ class InMemoryPackageIndex {
PackageSearchResult _search(
ServiceSearchQuery query,
BitArray packages,
IndexedScore<String> packageScores,
IndexedScore<String> Function() scoreFn,
) {
final predicateFilterCount = _filterOnPredicates(query, packages);
if (predicateFilterCount <= query.offset) {
return PackageSearchResult.empty();
}

// TODO: find a better way to handle predicate-only filtering and scoring
for (final index in packages.asIntIterable()) {
if (index >= _documents.length) break;
packageScores.setValue(index, 1.0);
}
final bestNameMatch = _bestNameMatch(query);
final bestNameIndex =
bestNameMatch == null ? null : _nameToIndex[bestNameMatch];

// do text matching
final parsedQueryText = query.parsedQuery.text;
final textResults = _searchText(
packageScores,
packages,
parsedQueryText,
textMatchExtent: query.textMatchExtent ?? TextMatchExtent.api,
);
_TextResults? textResults;
IndexedScore<String>? packageScores;

if (parsedQueryText != null && parsedQueryText.isNotEmpty) {
packageScores = scoreFn();
textResults = _searchText(
packageScores,
packages,
parsedQueryText,
textMatchExtent: query.textMatchExtent ?? TextMatchExtent.api,
);
if (textResults.hasNoMatch) {
return textResults.errorMessage == null
? PackageSearchResult.empty()
: PackageSearchResult.error(
errorMessage: textResults.errorMessage,
statusCode: 500,
);
}
}

final bestNameMatch = _bestNameMatch(query);
// The function takes the document index as parameter and returns whether
// it should be in the result set. When text search is applied, the
// [packageScores] contains the scores of the results, otherwise we are
// using the bitarray index of the filtering.
final selectFn = packageScores?.isPositive ?? packages.isSet;

// We know the total count at this point, we don't need to build the fully
// sorted result list to get the number. The best name match may insert an
// extra item, that will be addressed after the ranking score is determined.
var totalCount = packageScores?.positiveCount() ?? predicateFilterCount;

List<IndexedPackageHit> indexedHits;
switch (query.effectiveOrder ?? SearchOrder.top) {
Iterable<IndexedPackageHit> indexedHits;
switch (query.effectiveOrder) {
case SearchOrder.top:
if (textResults == null) {
indexedHits = _overallOrderedHits.whereInScores(packageScores);
case SearchOrder.text:
if (packageScores == null) {
indexedHits = _overallOrderedHits.whereInScores(selectFn);
break;
}

/// Adjusted score takes the overall score and transforms
/// it linearly into the [0.4-1.0] range, to allow better
/// multiplication outcomes.
packageScores.multiplyAllFromValues(_adjustedOverallScores);
indexedHits = _rankWithValues(
packageScores,
requiredLengthThreshold: query.offset,
bestNameMatch: bestNameMatch,
);
break;
case SearchOrder.text:
if (query.effectiveOrder == SearchOrder.top) {
/// Adjusted score takes the overall score and transforms
/// it linearly into the [0.4-1.0] range, to allow better
/// multiplication outcomes.
packageScores.multiplyAllFromValues(_adjustedOverallScores);
}
// Check whether the best name match will increase the total item count.
if (bestNameIndex != null &&
packageScores.getValue(bestNameIndex) <= 0.0) {
totalCount++;
}
indexedHits = _rankWithValues(
packageScores,
requiredLengthThreshold: query.offset,
bestNameMatch: bestNameMatch,
bestNameIndex: bestNameIndex ?? -1,
);
break;
case SearchOrder.created:
indexedHits = _createdOrderedHits.whereInScores(packageScores);
indexedHits = _createdOrderedHits.whereInScores(selectFn);
break;
case SearchOrder.updated:
indexedHits = _updatedOrderedHits.whereInScores(packageScores);
indexedHits = _updatedOrderedHits.whereInScores(selectFn);
break;
// ignore: deprecated_member_use
case SearchOrder.popularity:
case SearchOrder.downloads:
indexedHits = _downloadsOrderedHits.whereInScores(packageScores);
indexedHits = _downloadsOrderedHits.whereInScores(selectFn);
break;
case SearchOrder.like:
indexedHits = _likesOrderedHits.whereInScores(packageScores);
indexedHits = _likesOrderedHits.whereInScores(selectFn);
break;
case SearchOrder.points:
indexedHits = _pointsOrderedHits.whereInScores(packageScores);
indexedHits = _pointsOrderedHits.whereInScores(selectFn);
break;
case SearchOrder.trending:
indexedHits = _trendingOrderedHits.whereInScores(packageScores);
indexedHits = _trendingOrderedHits.whereInScores(selectFn);
break;
}

// bound by offset and limit (or randomize items)
final totalCount = indexedHits.length;
indexedHits =
boundedList(indexedHits, offset: query.offset, limit: query.limit);
// bound by offset and limit
indexedHits = indexedHits.skip(query.offset).take(query.limit);

late List<PackageHit> packageHits;
if ((query.textMatchExtent ?? TextMatchExtent.api).shouldMatchApi() &&
textResults != null &&
(textResults.topApiPages?.isNotEmpty ?? false)) {
packageHits = indexedHits.map((ps) {
final apiPages = textResults.topApiPages?[ps.index]
final apiPages = textResults!.topApiPages?[ps.index]
// TODO(https://github.com/dart-lang/pub-dev/issues/7106): extract title for the page
?.map((MapEntry<String, double> e) => ApiPageRef(path: e.key))
.toList();
Expand Down Expand Up @@ -380,33 +398,30 @@ class InMemoryPackageIndex {
}).toList();
}

_TextResults? _searchText(
_TextResults _searchText(
IndexedScore<String> packageScores,
BitArray packages,
String? text, {
String text, {
required TextMatchExtent textMatchExtent,
}) {
if (text == null || text.isEmpty) {
return null;
}

final sw = Stopwatch()..start();
final words = splitForQuery(text);
if (words.isEmpty) {
// packages.clearAll();
packageScores.fillRange(0, packageScores.length, 0);
return _TextResults.empty();
}

final matchName = textMatchExtent.shouldMatchName();
if (!matchName) {
// packages.clearAll();
packageScores.fillRange(0, packageScores.length, 0);
return _TextResults.empty(
errorMessage:
'Search index in reduced mode: unable to match query text.');
}

for (final index in packages.asIntIterable()) {
if (index >= _documents.length) break;
packageScores.setValue(index, 1.0);
}

bool aborted = false;
bool checkAborted() {
if (!aborted && sw.elapsed > _textSearchTimeout) {
Expand Down Expand Up @@ -500,19 +515,18 @@ class InMemoryPackageIndex {
List<IndexedPackageHit> _rankWithValues(
IndexedScore<String> score, {
// if the item count is fewer than this threshold, an empty list will be returned
int? requiredLengthThreshold,
String? bestNameMatch,
required int requiredLengthThreshold,
// note: when no best name match is applied, this parameter will be `-1`
required int bestNameIndex,
}) {
final list = <IndexedPackageHit>[];
final bestNameIndex =
bestNameMatch == null ? null : _nameToIndex[bestNameMatch];
for (var i = 0; i < score.length; i++) {
final value = score.getValue(i);
if (value <= 0.0 && i != bestNameIndex) continue;
list.add(IndexedPackageHit(
i, PackageHit(package: score.keys[i], score: value)));
}
if ((requiredLengthThreshold ?? 0) > list.length) {
if (requiredLengthThreshold > list.length) {
// There is no point to sort or even keep the results, as the search query offset ignores these anyway.
return [];
}
Expand Down Expand Up @@ -582,19 +596,22 @@ class InMemoryPackageIndex {
}

class _TextResults {
final bool hasNoMatch;
final List<List<MapEntry<String, double>>?>? topApiPages;
final String? errorMessage;

factory _TextResults.empty({String? errorMessage}) {
return _TextResults(
null,
errorMessage: errorMessage,
hasNoMatch: true,
);
}

_TextResults(
this.topApiPages, {
this.errorMessage,
this.hasNoMatch = false,
});
}

Expand Down Expand Up @@ -713,8 +730,8 @@ class _PkgNameData {
}

extension on List<IndexedPackageHit> {
List<IndexedPackageHit> whereInScores(IndexedScore scores) {
return where((h) => scores.isPositive(h.index)).toList();
Iterable<IndexedPackageHit> whereInScores(bool Function(int index) select) {
return where((h) => select(h.index));
}
}

Expand Down
6 changes: 2 additions & 4 deletions app/lib/search/search_service.dart
Original file line number Diff line number Diff line change
Expand Up @@ -282,13 +282,11 @@ class ServiceSearchQuery {
/// - URL query sort [order] is used as a fallback.
///
/// TODO: remove this field when [order] is removed.
late final effectiveOrder = parsedQuery.order ?? order;
late final effectiveOrder = parsedQuery.order ?? order ?? SearchOrder.top;
bool get _hasQuery => query != null && query!.isNotEmpty;
bool get _hasOnlyFreeText => _hasQuery && parsedQuery.hasOnlyFreeText;
bool get isNaturalOrder =>
effectiveOrder == null ||
effectiveOrder == SearchOrder.top ||
effectiveOrder == SearchOrder.text;
effectiveOrder == SearchOrder.top || effectiveOrder == SearchOrder.text;
bool get _hasNoOwnershipScope => publisherId == null;
bool get _isFlutterFavorite =>
tagsPredicate.hasTag(PackageTags.isFlutterFavorite);
Expand Down
26 changes: 26 additions & 0 deletions app/lib/search/token_index.dart
Original file line number Diff line number Diff line change
Expand Up @@ -185,6 +185,24 @@ abstract class _AllocationPool<T> {
_release(item);
return r;
}

R withItemGetter<R>(R Function(T Function() itemFn) fn) {
List<T>? items;
T itemFn() {
items ??= <T>[];
final item = _acquire();
items!.add(item);
return item;
}

final r = fn(itemFn);
if (items != null) {
for (final item in items!) {
_release(item);
}
}
return r;
}
}

/// A reusable pool for [IndexedScore] instances to spare some memory allocation.
Expand Down Expand Up @@ -225,6 +243,14 @@ class IndexedScore<K> {
List<K> get keys => _keys;
late final length = _values.length;

int positiveCount() {
var count = 0;
for (var i = 0; i < length; i++) {
if (isPositive(i)) count++;
}
return count;
}

bool isPositive(int index) {
return _values[index] > 0.0;
}
Expand Down
16 changes: 0 additions & 16 deletions app/lib/shared/utils.dart
Original file line number Diff line number Diff line change
Expand Up @@ -154,22 +154,6 @@ String contentType(String name) {
return mime.defaultExtensionMap[ext] ?? 'application/octet-stream';
}

/// Returns a subset of the list, bounded by [offset] and [limit].
List<T> boundedList<T>(List<T> list, {int offset = 0, int limit = 0}) {
Iterable<T> iterable = list;
if (offset > 0) {
if (offset >= list.length) {
return <T>[];
} else {
iterable = iterable.skip(offset);
}
}
if (limit > 0) {
iterable = iterable.take(limit);
}
return iterable.toList();
}

/// Returns a UUID in v4 format as a `String`.
///
/// If [bytes] is provided, it must be length 16 and have values between `0` and
Expand Down
2 changes: 2 additions & 0 deletions app/lib/third_party/bit_array/bit_array.dart
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,8 @@ class BitArray extends BitSet {
return BitArray._(data);
}

bool isSet(int index) => this[index];

/// The value of the bit with the specified [index].
@override
bool operator [](int index) {
Expand Down
24 changes: 0 additions & 24 deletions app/test/shared/utils_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -8,30 +8,6 @@ import 'package:pub_semver/pub_semver.dart';
import 'package:test/test.dart';

void main() {
group('boundedList', () {
final numbers10 = List.generate(10, (i) => i);

test('empty bounds', () {
expect(boundedList(numbers10), numbers10);
});

test('offset only', () {
expect(boundedList(numbers10, offset: 6), [6, 7, 8, 9]);
expect(boundedList(numbers10, offset: 16), []);
});

test('limit only', () {
expect(boundedList(numbers10, limit: 0), numbers10);
expect(boundedList(numbers10, limit: 3), [0, 1, 2]);
expect(boundedList(numbers10, limit: 13), numbers10);
});

test('offset and limit', () {
expect(boundedList(numbers10, offset: 1, limit: 3), [1, 2, 3]);
expect(boundedList(numbers10, offset: 9, limit: 10), [9]);
});
});

group('uuid', () {
test('format known UUId', () {
expect(createUuid(List<int>.filled(16, 0)),
Expand Down