279 lines
6.9 KiB
Dart
279 lines
6.9 KiB
Dart
import 'dart:math' as math;
|
|
|
|
import 'package:collection/collection.dart';
|
|
import 'package:weblibre/features/search/domain/entities/abstract/i_query_builder.dart';
|
|
import 'package:weblibre/features/search/domain/entities/bareword.dart';
|
|
import 'package:weblibre/features/search/domain/unix_tokenizer.dart';
|
|
|
|
typedef _Phrase = List<Bareword>;
|
|
|
|
sealed class FtsQueryBuilder {
|
|
///Matching quoted strings like "xda bda" (group 1), as well as strings delimetered by
|
|
///a whitespace (group 2)
|
|
static final _tokenizePattern = RegExp('"([^"]+)"|([^ ]+)');
|
|
|
|
///Reserved FTS5 baewords
|
|
static const _reservedBarewords = {'AND', 'OR', 'NOT'};
|
|
|
|
///Checks if string is an bareword accoring to:
|
|
///https://www.sqlite.org/fts5.html#fts5_strings
|
|
///
|
|
///Elsewise ths string needs to get quoted
|
|
static final _barewordPattern = RegExp(
|
|
r'^([^\x00-\x7F]|[\w]|[\d]|[_]|[\x1A])+$',
|
|
);
|
|
|
|
static bool _isReservedBareword(Bareword input) =>
|
|
_reservedBarewords.contains(input.word);
|
|
|
|
late final _Phrase _tokens;
|
|
|
|
bool get hasTokens => _tokens.isNotEmpty;
|
|
|
|
FtsQueryBuilder.tokenize({
|
|
required String input,
|
|
required int minTokenLength,
|
|
required int tokenLimit,
|
|
}) {
|
|
final matches = _tokenizePattern.allMatches(input);
|
|
|
|
final barewords = matches
|
|
.map((match) {
|
|
if (match.group(1) != null) {
|
|
return EnclosedBareword(match.group(1)!);
|
|
} else if (_barewordPattern.hasMatch(match.group(2)!)) {
|
|
return SimpleBareword(match.group(2)!);
|
|
} else {
|
|
return EnclosedBareword(match.group(2)!);
|
|
}
|
|
})
|
|
.where((token) => token.word.isNotEmpty)
|
|
.whereNot(_isReservedBareword)
|
|
.toList();
|
|
|
|
//Merge short tokens
|
|
_mergeShortBarewords(barewords, minTokenLength);
|
|
|
|
_tokens = barewords
|
|
.where((bareword) => bareword.word.length >= minTokenLength)
|
|
.take(tokenLimit)
|
|
.toList();
|
|
}
|
|
|
|
static void _mergeShortBarewords(
|
|
List<Bareword> barewords,
|
|
int minTokenLength,
|
|
) {
|
|
for (var i = 0; i < barewords.length; i++) {
|
|
final bareword = barewords[i];
|
|
|
|
if (i > 0) {
|
|
if (bareword.word.length < minTokenLength) {
|
|
barewords[i] = barewords[i - 1].join(bareword);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
//Get weighted groups of tokens
|
|
//Weight depends on order
|
|
static List<_Phrase> _getWeighted(List<Bareword> tokens) {
|
|
if (tokens.isEmpty) {
|
|
return [];
|
|
}
|
|
|
|
final phrases = List.generate(
|
|
tokens.length - 1,
|
|
(index) => tokens.sublist(0, tokens.length - index),
|
|
);
|
|
|
|
phrases.addAll(tokens.map((token) => [token]));
|
|
|
|
return phrases;
|
|
}
|
|
|
|
//Get all combinations of input tokens and treat them as equally
|
|
//Match of bigger group will have greater score
|
|
static List<_Phrase> _getCombinations(List<Bareword> tokens) {
|
|
if (tokens.isEmpty) {
|
|
return [];
|
|
}
|
|
|
|
final combinations = <_Phrase>[];
|
|
final slent = math.pow(2, tokens.length);
|
|
|
|
for (var i = 0; i < slent; i++) {
|
|
final temp = <Bareword>[];
|
|
|
|
for (var j = 0; j < tokens.length; j++) {
|
|
if ((i & math.pow(2, j).toInt()) > 0) {
|
|
temp.add(tokens[j]);
|
|
}
|
|
}
|
|
|
|
if (temp.isNotEmpty) {
|
|
combinations.add(temp);
|
|
}
|
|
}
|
|
|
|
return combinations;
|
|
}
|
|
|
|
//If we have multiple unenclosed token, combine them into quoted to
|
|
//get a better full range match
|
|
//TODO: Before migration this was only used for non-prefix tokens, check for side effects
|
|
static List<_Phrase> _combineUnenclosed(_Phrase phrase) {
|
|
return [
|
|
phrase,
|
|
if (phrase.length > 2 &&
|
|
phrase.every((bareword) => bareword is SimpleBareword))
|
|
[EnclosedBareword(phrase.join(barewordConcat))],
|
|
];
|
|
}
|
|
|
|
//Decinding query strategy
|
|
static List<_Phrase> _generatePhrases(List<Bareword> tokens) {
|
|
if (tokens.length <= 4) {
|
|
return _getCombinations(tokens);
|
|
} else if (tokens.length <= 8) {
|
|
return _getWeighted(tokens);
|
|
} else {
|
|
return [tokens];
|
|
}
|
|
}
|
|
|
|
static _Phrase _removeDependendBarewords(List<Bareword> phrase) {
|
|
final joinedPhrase = List.of(phrase);
|
|
|
|
for (var i = 0; i < joinedPhrase.length; i++) {
|
|
final bareword = joinedPhrase[i];
|
|
|
|
//Remove dependend phrases
|
|
if (joinedPhrase.whereType<JoinedBareword>().any(
|
|
(joined) => joined.dependencies.contains(bareword),
|
|
)) {
|
|
joinedPhrase.removeAt(i);
|
|
i--;
|
|
}
|
|
}
|
|
|
|
return joinedPhrase;
|
|
}
|
|
|
|
String _buildQuery(
|
|
List<Bareword> tokens,
|
|
String Function(List<Bareword> phrase) barewordConcat,
|
|
String phraseConcat,
|
|
) {
|
|
final phrases = _generatePhrases(
|
|
tokens,
|
|
).map(_removeDependendBarewords).expand(_combineUnenclosed);
|
|
|
|
return phrases
|
|
.where((phrase) => phrase.isNotEmpty)
|
|
.sortedBy<num>((x) => x.length)
|
|
.reversed
|
|
.map(barewordConcat)
|
|
.toSet()
|
|
.join(phraseConcat);
|
|
}
|
|
|
|
String build();
|
|
}
|
|
|
|
final class PrefixQueryBuilder extends FtsQueryBuilder {
|
|
PrefixQueryBuilder.tokenize({
|
|
required super.input,
|
|
required super.minTokenLength,
|
|
required super.tokenLimit,
|
|
}) : super.tokenize();
|
|
|
|
@override
|
|
String build() {
|
|
return _buildQuery(_tokens, (phrase) {
|
|
if (phrase.length == 1) {
|
|
return '${phrase.first}*';
|
|
}
|
|
|
|
return 'NEAR(${phrase.map((bareword) => '$bareword*').join(' ')})';
|
|
}, ' OR ');
|
|
}
|
|
}
|
|
|
|
final class TrigramQueryBuilder extends FtsQueryBuilder {
|
|
TrigramQueryBuilder.tokenize({
|
|
required super.input,
|
|
required super.minTokenLength,
|
|
required super.tokenLimit,
|
|
}) : super.tokenize();
|
|
|
|
@override
|
|
String build() {
|
|
return _buildQuery(_tokens, (phrase) => phrase.join(' '), ' OR ');
|
|
}
|
|
}
|
|
|
|
mixin PrefixQueryBuilderMixin implements IQueryBuilder {
|
|
@override
|
|
String buildFtsQuery(String input) {
|
|
final queryBuilder = PrefixQueryBuilder.tokenize(
|
|
input: input,
|
|
minTokenLength: ftsMinTokenLength,
|
|
tokenLimit: ftsTokenLimit,
|
|
);
|
|
|
|
if (queryBuilder.hasTokens) {
|
|
return queryBuilder.build();
|
|
}
|
|
|
|
return '';
|
|
}
|
|
|
|
@override
|
|
String buildLikeQuery(String input) {
|
|
final likeQueryBuilder = UnixLikeQueryBuilder.tokenize(
|
|
input: input,
|
|
minTokenLength: 1,
|
|
tokenLimit: 5,
|
|
);
|
|
|
|
if (likeQueryBuilder.hasTokens) {
|
|
return likeQueryBuilder.build();
|
|
}
|
|
|
|
return '';
|
|
}
|
|
}
|
|
|
|
mixin TrigramQueryBuilderMixin implements IQueryBuilder {
|
|
@override
|
|
String buildFtsQuery(String input) {
|
|
final queryBuilder = TrigramQueryBuilder.tokenize(
|
|
input: input,
|
|
minTokenLength: ftsMinTokenLength,
|
|
tokenLimit: ftsTokenLimit,
|
|
);
|
|
|
|
if (queryBuilder.hasTokens) {
|
|
return queryBuilder.build();
|
|
}
|
|
|
|
return '';
|
|
}
|
|
|
|
@override
|
|
String buildLikeQuery(String input) {
|
|
final likeQueryBuilder = UnixLikeQueryBuilder.tokenize(
|
|
input: input,
|
|
minTokenLength: 1,
|
|
tokenLimit: 5,
|
|
);
|
|
|
|
if (likeQueryBuilder.hasTokens) {
|
|
return likeQueryBuilder.build();
|
|
}
|
|
|
|
return '';
|
|
}
|
|
}
|