Skip to content

Commit

Permalink
Weighted search
Browse files Browse the repository at this point in the history
  • Loading branch information
comigor committed Dec 23, 2019
1 parent c02852c commit 9e9856e
Show file tree
Hide file tree
Showing 5 changed files with 163 additions and 19 deletions.
25 changes: 21 additions & 4 deletions lib/data/fuzzy_options.dart
@@ -1,8 +1,25 @@
import 'result.dart';

typedef SorterFn<T> = int Function(Result<T> a, Result<T> b);
/// Represents a weighted getter of an item
class WeightedKey<T> {
/// Instantiates it
WeightedKey({
this.name,
this.getter,
this.weight,
}) : assert(weight > 0 && weight <= 1);

/// Name of this getter
final String name;

/// Getter to a specifc string inside item
final String Function(T obj) getter;

/// Weight of this getter
final double weight;
}

typedef GetterFn<T> = String Function(T obj);
typedef SorterFn<T> = int Function(Result<T> a, Result<T> b);

int _defaultSortFn<T>(Result<T> a, Result<T> b) => a.score.compareTo(b.score);

Expand Down Expand Up @@ -59,8 +76,8 @@ class FuzzyOptions<T> {
/// Minimum number of characters that must be matched before a result is considered a match
final int minMatchCharLength;

/// List of getters to properties that will be searched
final List<GetterFn<T>> keys;
/// List of weighted getters to properties that will be searched
final List<WeightedKey<T>> keys;

/// Whether to sort the result list, by score
final bool shouldSort;
Expand Down
4 changes: 4 additions & 0 deletions lib/data/result.dart
Expand Up @@ -61,13 +61,17 @@ class Result<T> {
class ResultDetails<T> {
/// Instantiates it
ResultDetails({
this.key = '',
this.arrayIndex,
this.value,
this.score,
this.matchedIndices,
this.nScore,
});

/// Key ([WeightedKey.name]) used to create this
final String key;

/// Index of result in the original list
final int arrayIndex;

Expand Down
27 changes: 18 additions & 9 deletions lib/fuzzy.dart
@@ -1,3 +1,5 @@
library fuzzy;

import 'dart:math';

import 'bitap/bitap.dart';
Expand Down Expand Up @@ -69,12 +71,13 @@ class Fuzzy<T> {
final results = <Result<T>>[];
final resultMap = <int, Result<T>>{};

// Check the first item in the list, if it's a string, then we assume
// Check the first item in the list, if it's a string, then we assume
// that every item in the list is also a string, and thus it's a flattened array.
if (list[0] is String) {
// Iterate over every item
for (var i = 0, len = list.length; i < len; i += 1) {
_analyze(
key: '',
value: list[i].toString(),
record: list[i],
index: i,
Expand All @@ -95,10 +98,14 @@ class Fuzzy<T> {
final item = list[i];
// Iterate over every key
for (var j = 0; j < options.keys.length; j += 1) {
final value = options.keys[j](item);
weights.update(value, (_) => 1.0, ifAbsent: () => 1.0);
final key = options.keys[j].name;
final value = options.keys[j].getter(item);

final weight = 1.0 - options.keys[j].weight ?? 0.0;
weights.update(key, (_) => weight, ifAbsent: () => weight);

_analyze(
key: key,
value: value,
record: list[i],
index: i,
Expand All @@ -114,6 +121,7 @@ class Fuzzy<T> {
}

List<Result<T>> _analyze({
String key = '',
int arrayIndex = -1,
String value,
T record,
Expand Down Expand Up @@ -195,6 +203,7 @@ class Fuzzy<T> {
// Use the lowest score
// existingResult.score, bitapResult.score
existingResult.matches.add(ResultDetails<T>(
key: key,
arrayIndex: arrayIndex,
value: value,
score: finalScore,
Expand All @@ -206,6 +215,7 @@ class Fuzzy<T> {
item: record,
matches: [
ResultDetails<T>(
key: key,
arrayIndex: arrayIndex,
value: value,
score: finalScore,
Expand Down Expand Up @@ -238,9 +248,10 @@ class Fuzzy<T> {
var bestScore = 1.0;

for (var j = 0; j < scoreLen; j += 1) {
final weight = weights[matches[j].value] ?? 1;
final score =
weight == 1 ? matches[j].score : (matches[j].score ?? 0.001);
final weight = weights[matches[j].key] ?? 1.0;
final score = weight == 1.0
? matches[j].score
: (matches[j].score == 0.0 ? 0.001 : matches[j].score);
final nScore = score * weight;

if (weight != 1) {
Expand All @@ -251,9 +262,7 @@ class Fuzzy<T> {
}
}

results[i].score = bestScore == 1 ? currScore : bestScore;

_log('${results[i]}');
results[i].score = bestScore == 1.0 ? currScore : bestScore;
}
}

Expand Down
37 changes: 37 additions & 0 deletions test/fixtures/books.dart
@@ -0,0 +1,37 @@
class Book {
Book({
this.title,
this.author,
this.tags = const [],
});

final String title;
final String author;
final List<String> tags;

@override
String toString() => '$title, $author';
}

final customBookList = [
Book(
title: 'Old Man\'s War fiction',
author: 'John X',
tags: ['war'],
),
Book(
title: 'Right Ho Jeeves',
author: 'P.D. Mans',
tags: ['fiction', 'war'],
),
Book(
title: 'The life of Jane',
author: 'John Smith',
tags: ['john', 'smith'],
),
Book(
title: 'John Smith',
author: 'Steve Pearson',
tags: ['steve', 'pearson'],
),
];
89 changes: 83 additions & 6 deletions test/fuzzy_test.dart
@@ -1,6 +1,8 @@
import 'package:fuzzy/fuzzy.dart';
import 'package:test/test.dart';

import 'fixtures/books.dart';

final defaultList = ['Apple', 'Orange', 'Banana'];
final defaultOptions = FuzzyOptions(
location: 0,
Expand All @@ -19,7 +21,7 @@ final defaultOptions = FuzzyOptions(
);

Fuzzy setup({
List<String> itemList,
List itemList,
FuzzyOptions overwriteOptions,
}) {
return Fuzzy(
Expand Down Expand Up @@ -102,12 +104,87 @@ void main() {
});
});

group('Weighted search', () {
Fuzzy fuse;
setUp(() {
fuse = setup();
group('Weighted search on typed list', () {
test('When searching for the term "John Smith" with author weighted higher',
() {
final fuse = Fuzzy<Book>(
customBookList,
options: FuzzyOptions(keys: [
WeightedKey(getter: (i) => i.title, weight: 0.3, name: 'title'),
WeightedKey(getter: (i) => i.author, weight: 0.7, name: 'author'),
]),
);
final result = fuse.search('John Smith');

expect(result[0].item, customBookList[2],
reason: 'We get the the exactly matching object');
});
}, skip: true);

test('When searching for the term "John Smith" with title weighted higher',
() {
final fuse = Fuzzy<Book>(
customBookList,
options: FuzzyOptions(keys: [
WeightedKey(getter: (i) => i.title, weight: 0.7, name: 'title'),
WeightedKey(getter: (i) => i.author, weight: 0.3, name: 'author'),
]),
);
final result = fuse.search('John Smith');

expect(result[0].item, customBookList[3],
reason: 'We get the the exactly matching object');
});

test(
'When searching for the term "Man", where the author is weighted higher than title',
() {
final fuse = Fuzzy<Book>(
customBookList,
options: FuzzyOptions(keys: [
WeightedKey(getter: (i) => i.title, weight: 0.3, name: 'title'),
WeightedKey(getter: (i) => i.author, weight: 0.7, name: 'author'),
]),
);
final result = fuse.search('Man');

expect(result[0].item, customBookList[1],
reason: 'We get the the exactly matching object');
});

test(
'When searching for the term "Man", where the title is weighted higher than author',
() {
final fuse = Fuzzy<Book>(
customBookList,
options: FuzzyOptions(keys: [
WeightedKey(getter: (i) => i.title, weight: 0.7, name: 'title'),
WeightedKey(getter: (i) => i.author, weight: 0.3, name: 'author'),
]),
);
final result = fuse.search('Man');

expect(result[0].item, customBookList[0],
reason: 'We get the the exactly matching object');
});

test(
'When searching for the term "War", where tags are weighted higher than all other keys',
() {
final fuse = Fuzzy<Book>(
customBookList,
options: FuzzyOptions(keys: [
WeightedKey(getter: (i) => i.title, weight: 0.8, name: 'title'),
WeightedKey(getter: (i) => i.author, weight: 0.3, name: 'author'),
WeightedKey(
getter: (i) => i.tags.join(' '), weight: 0.9, name: 'tags'),
]),
);
final result = fuse.search('War');

expect(result[0].item, customBookList[0],
reason: 'We get the the exactly matching object');
});
});

group('Search with match all tokens', () {
Fuzzy fuse;
Expand Down

0 comments on commit 9e9856e

Please sign in to comment.