Rework library search to use an AST-based approach (#3554)
* Rework library search to use an AST-based approach * Spotless apply * Move the filtering logic to the database * Spotless apply * Cleanup and separation of concerns * Add `notes` to field and general search, and chapter read, total chapters to comparison search * Handle searching for empty fields * Add `language` as a field-only query option * Clean up SQL clause generations * Spotless apply * Do not parse `-` as a negation when followed by a separator * Revert moving filtering logic to database * Changelog * Use contains for genre instead of equals * Add special handling for source name * Add field-only query for source id * Apply suggestions
This commit is contained in:
@@ -0,0 +1,92 @@
|
||||
package mihon.domain.library.model.search
|
||||
|
||||
enum class MangaField(vararg val aliases: String, val fieldOnly: Boolean = false) {
|
||||
TITLE("title"),
|
||||
AUTHOR("author"),
|
||||
ARTIST("artist"),
|
||||
DESCRIPTION("description", "desc"),
|
||||
GENRE("genre", "tag"),
|
||||
SOURCE("source", "src"),
|
||||
NOTES("notes", "note"),
|
||||
LANGUAGE("language", "lang", fieldOnly = true),
|
||||
SOURCE_ID("source_id", "sourceid", "src_id", "srcid", fieldOnly = true),
|
||||
;
|
||||
|
||||
companion object {
|
||||
private val lookup = entries.flatMap { field ->
|
||||
field.aliases.map { it.lowercase() to field }
|
||||
}.toMap()
|
||||
|
||||
fun fromString(value: String): MangaField? = lookup[value.lowercase()]
|
||||
}
|
||||
}
|
||||
|
||||
enum class ComparisonField(vararg val aliases: String) {
|
||||
ID("id"),
|
||||
DATE_ADDED("added"),
|
||||
FETCH_INTERVAL("fetchinterval", "fi"),
|
||||
NEXT_UPDATE("nextupdate", "nu"),
|
||||
UNREAD("unread"),
|
||||
READ("read"),
|
||||
TOTAL("total"),
|
||||
;
|
||||
|
||||
companion object {
|
||||
private val lookup = entries.flatMap { field ->
|
||||
field.aliases.map { it.lowercase() to field }
|
||||
}.toMap()
|
||||
|
||||
fun fromString(value: String): ComparisonField? = lookup[value.lowercase()]
|
||||
}
|
||||
}
|
||||
|
||||
enum class Comparator(val symbol: String) {
|
||||
GTE(">="),
|
||||
LTE("<="),
|
||||
GT(">"),
|
||||
LT("<"),
|
||||
EQ("="),
|
||||
;
|
||||
|
||||
fun <T : Comparable<T>> apply(a: T, b: T): Boolean = when (this) {
|
||||
GTE -> a >= b
|
||||
LTE -> a <= b
|
||||
GT -> a > b
|
||||
LT -> a < b
|
||||
EQ -> a == b
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val lookup = entries.associateBy { it.symbol }
|
||||
|
||||
fun fromString(value: String): Comparator? = lookup[value]
|
||||
}
|
||||
}
|
||||
|
||||
sealed interface QueryNode {
|
||||
companion object {
|
||||
fun from(query: String): QueryNode {
|
||||
val tokens = LibrarySearchLexer.tokenize(query)
|
||||
return LibrarySearchParser(tokens).parse()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
data class AndNode(val children: List<QueryNode>) : QueryNode
|
||||
|
||||
data class OrNode(val children: List<QueryNode>) : QueryNode
|
||||
|
||||
data class NotNode(val child: QueryNode) : QueryNode
|
||||
|
||||
object EmptyQueryNode : QueryNode
|
||||
|
||||
data class GeneralQueryNode(val value: String, val negated: Boolean) : QueryNode
|
||||
|
||||
data class FieldQueryNode(val field: MangaField, val value: String, val negated: Boolean) : QueryNode
|
||||
|
||||
data class ComparisonQueryNode(
|
||||
val field: ComparisonField,
|
||||
val value: String,
|
||||
val queryComparator: Comparator,
|
||||
val negated: Boolean,
|
||||
) : QueryNode
|
||||
@@ -0,0 +1,93 @@
|
||||
package mihon.domain.library.model.search
|
||||
|
||||
import kotlin.text.get
|
||||
|
||||
object LibrarySearchLexer {
|
||||
|
||||
private val regex = Regex(
|
||||
"""
|
||||
# Operators
|
||||
(?<LParen> \( )|
|
||||
(?<RParen> \) )|
|
||||
(?<NOT> -(?![\s,]) )|
|
||||
(?<OR> \|\| )|
|
||||
(?<AND> && )|
|
||||
|
||||
# Comparison fields
|
||||
(?<CompField> [a-zA-Z_][a-zA-Z0-9_]* )
|
||||
(?<Comparator> >=|<=|>|<|= )
|
||||
(?: " (?<CompValQuoted> [^"]* ) " | ' (?<CompValSingleQuoted> [^']* ) ' | (?<CompVal> [^\s,()]+ ))|
|
||||
|
||||
# Key-Value fields
|
||||
(?<Field> [a-zA-Z_][a-zA-Z0-9_]* ) :
|
||||
(?: " (?<FieldValQuoted> [^"]* ) " | ' (?<FieldValSingleQuoted> [^']* ) ' | (?<FieldVal> [^\s,()]+ ))|
|
||||
|
||||
# General catch-all
|
||||
(?: " (?<GeneralQuoted> [^"]* ) " | ' (?<GeneralSingleQuoted> [^']* ) ' | (?<General> [^\s,()]+ ))|
|
||||
|
||||
# Seperator
|
||||
(?<Separator> [\s,]+ )
|
||||
""".trimIndent(),
|
||||
RegexOption.COMMENTS,
|
||||
)
|
||||
|
||||
sealed interface Token {
|
||||
data object LParen : Token
|
||||
data object RParen : Token
|
||||
data object And : Token
|
||||
data object Or : Token
|
||||
data object Not : Token
|
||||
data class CompField(val field: String, val comparator: String, val value: String) : Token
|
||||
data class Field(val field: String, val value: String) : Token
|
||||
data class General(val value: String) : Token
|
||||
}
|
||||
|
||||
fun tokenize(input: String): List<Token> {
|
||||
val tokens = mutableListOf<Token>()
|
||||
val matches = regex.findAll(input)
|
||||
|
||||
for (match in matches) {
|
||||
val groups = match.groups
|
||||
when {
|
||||
groups["Separator"] != null -> continue
|
||||
groups["AND"] != null -> tokens.add(Token.And)
|
||||
groups["OR"] != null -> tokens.add(Token.Or)
|
||||
groups["NOT"] != null -> tokens.add(Token.Not)
|
||||
groups["LParen"] != null -> tokens.add(Token.LParen)
|
||||
groups["RParen"] != null -> tokens.add(Token.RParen)
|
||||
|
||||
groups["CompField"] != null -> {
|
||||
tokens.add(
|
||||
Token.CompField(
|
||||
field = groups["CompField"]!!.value,
|
||||
comparator = groups["Comparator"]!!.value,
|
||||
value = groups["CompValQuoted"]?.value
|
||||
?: groups["CompValSingleQuoted"]?.value
|
||||
?: groups["CompVal"]!!.value,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
groups["Field"] != null -> {
|
||||
tokens.add(
|
||||
Token.Field(
|
||||
field = groups["Field"]!!.value,
|
||||
value = groups["FieldValQuoted"]?.value
|
||||
?: groups["FieldValSingleQuoted"]?.value
|
||||
?: groups["FieldVal"]!!.value,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
else -> {
|
||||
val value = groups["GeneralQuoted"]?.value
|
||||
?: groups["GeneralSingleQuoted"]?.value
|
||||
?: groups["General"]!!.value
|
||||
tokens.add(Token.General(value))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return tokens
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
package mihon.domain.library.model.search
|
||||
|
||||
class LibrarySearchParser(private val tokens: List<LibrarySearchLexer.Token>) {
|
||||
private var index = 0
|
||||
|
||||
private fun peek(): LibrarySearchLexer.Token? = tokens.getOrNull(index)
|
||||
private fun advance(): LibrarySearchLexer.Token? = tokens.getOrNull(index++)
|
||||
|
||||
fun parse(): QueryNode {
|
||||
if (tokens.isEmpty()) return AndNode(emptyList())
|
||||
return parseOr()
|
||||
}
|
||||
|
||||
private fun parseOr(): QueryNode {
|
||||
val nodes = mutableListOf<QueryNode>()
|
||||
nodes.add(parseAnd())
|
||||
while (peek() is LibrarySearchLexer.Token.Or) {
|
||||
advance()
|
||||
nodes.add(parseAnd())
|
||||
}
|
||||
return if (nodes.size == 1) nodes.first() else OrNode(nodes)
|
||||
}
|
||||
|
||||
private fun parseAnd(): QueryNode {
|
||||
val nodes = mutableListOf<QueryNode>()
|
||||
|
||||
while (index < tokens.size && peek() !is LibrarySearchLexer.Token.Or &&
|
||||
peek() !is LibrarySearchLexer.Token.RParen
|
||||
) {
|
||||
if (peek() is LibrarySearchLexer.Token.And) {
|
||||
advance()
|
||||
}
|
||||
nodes.add(parseTerm())
|
||||
}
|
||||
|
||||
val filteredNodes = nodes.filter { it !is EmptyQueryNode }
|
||||
if (filteredNodes.isEmpty()) return EmptyQueryNode
|
||||
return if (filteredNodes.size == 1) filteredNodes.first() else AndNode(filteredNodes)
|
||||
}
|
||||
|
||||
private fun parseTerm(): QueryNode {
|
||||
var negated = false
|
||||
|
||||
while (peek() is LibrarySearchLexer.Token.Not) {
|
||||
advance()
|
||||
negated = !negated
|
||||
}
|
||||
|
||||
if (peek() is LibrarySearchLexer.Token.LParen) {
|
||||
advance()
|
||||
|
||||
val subTree = parseOr()
|
||||
|
||||
if (peek() is LibrarySearchLexer.Token.RParen) {
|
||||
advance()
|
||||
}
|
||||
|
||||
if (negated) {
|
||||
return NotNode(subTree)
|
||||
}
|
||||
return subTree
|
||||
}
|
||||
|
||||
return when (val nextToken = advance()) {
|
||||
is LibrarySearchLexer.Token.General -> GeneralQueryNode(nextToken.value, negated)
|
||||
is LibrarySearchLexer.Token.Field -> {
|
||||
MangaField.fromString(nextToken.field)?.let {
|
||||
FieldQueryNode(it, nextToken.value, negated)
|
||||
} ?: GeneralQueryNode("${nextToken.field}:${nextToken.value}", negated)
|
||||
}
|
||||
is LibrarySearchLexer.Token.CompField -> {
|
||||
ComparisonField.fromString(nextToken.field)?.let {
|
||||
ComparisonQueryNode(it, nextToken.value, Comparator.fromString(nextToken.comparator)!!, negated)
|
||||
} ?: GeneralQueryNode("${nextToken.field}${nextToken.comparator}${nextToken.value}", negated)
|
||||
}
|
||||
else -> EmptyQueryNode
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user