2021-06-01 21:09:03 -04:00

216 lines
7.7 KiB
Kotlin
Executable File

package exh.search
import exh.metadata.sql.tables.SearchMetadataTable
import exh.metadata.sql.tables.SearchTagTable
import exh.metadata.sql.tables.SearchTitleTable
import java.util.Locale
class SearchEngine {
private val queryCache = mutableMapOf<String, List<QueryComponent>>()
fun textToSubQueries(
namespace: String?,
component: Text?
): Pair<String, List<String>>? {
val maybeLenientComponent = component?.let {
if (!it.exact) {
it.asLenientTagQueries()
} else {
listOf(it.asQuery())
}
}
val componentTagQuery = maybeLenientComponent?.let {
val params = mutableListOf<String>()
it.joinToString(separator = " OR ", prefix = "(", postfix = ")") { q ->
params += q
"${SearchTagTable.TABLE}.${SearchTagTable.COL_NAME} LIKE ?"
} to params
}
return when {
namespace != null -> {
var query =
"""
(SELECT ${SearchTagTable.COL_MANGA_ID} AS $COL_MANGA_ID FROM ${SearchTagTable.TABLE}
WHERE ${SearchTagTable.COL_NAMESPACE} IS NOT NULL
AND ${SearchTagTable.COL_NAMESPACE} LIKE ?
""".trimIndent()
val params = mutableListOf(escapeLike(namespace))
if (componentTagQuery != null) {
query += "\n AND ${componentTagQuery.first}"
params += componentTagQuery.second
}
"$query)" to params
}
component != null -> {
// Match title + tags
val tagQuery =
"""
SELECT ${SearchTagTable.COL_MANGA_ID} AS $COL_MANGA_ID FROM ${SearchTagTable.TABLE}
WHERE ${componentTagQuery!!.first}
""".trimIndent() to componentTagQuery.second
val titleQuery =
"""
SELECT ${SearchTitleTable.COL_MANGA_ID} AS $COL_MANGA_ID FROM ${SearchTitleTable.TABLE}
WHERE ${SearchTitleTable.COL_TITLE} LIKE ?
""".trimIndent() to listOf(component.asLenientTitleQuery())
"(${tagQuery.first} UNION ${titleQuery.first})".trimIndent() to
(tagQuery.second + titleQuery.second)
}
else -> null
}
}
fun queryToSql(q: List<QueryComponent>): Pair<String, List<String>> {
val wheres = mutableListOf<String>()
val whereParams = mutableListOf<String>()
val include = mutableListOf<Pair<String, List<String>>>()
val exclude = mutableListOf<Pair<String, List<String>>>()
q.forEach { component ->
val query = if (component is Text) {
textToSubQueries(null, component)
} else if (component is Namespace) {
if (component.namespace == "uploader") {
wheres += "meta.${SearchMetadataTable.COL_UPLOADER} LIKE ?"
whereParams += component.tag!!.rawTextEscapedForLike()
null
} else {
if (component.tag!!.components.size > 0) {
// Match namespace + tags
textToSubQueries(component.namespace, component.tag)
} else {
// Perform namespace search
textToSubQueries(component.namespace, null)
}
}
} else error("Unknown query component!")
if (query != null) {
(if (component.excluded) exclude else include) += query
}
}
val completeParams = mutableListOf<String>()
var baseQuery =
"""
SELECT ${SearchMetadataTable.COL_MANGA_ID}
FROM ${SearchMetadataTable.TABLE} meta
""".trimIndent()
include.forEachIndexed { index, pair ->
baseQuery += "\n" + (
"""
INNER JOIN ${pair.first} i$index
ON i$index.$COL_MANGA_ID = meta.${SearchMetadataTable.COL_MANGA_ID}
""".trimIndent()
)
completeParams += pair.second
}
exclude.forEach {
wheres += """
(meta.${SearchMetadataTable.COL_MANGA_ID} NOT IN ${it.first})
""".trimIndent()
whereParams += it.second
}
if (wheres.isNotEmpty()) {
completeParams += whereParams
baseQuery += "\nWHERE\n"
baseQuery += wheres.joinToString("\nAND\n")
}
baseQuery += "\nORDER BY ${SearchMetadataTable.COL_MANGA_ID}"
return baseQuery to completeParams
}
fun parseQuery(query: String, enableWildcard: Boolean = true) = queryCache.getOrPut(query) {
val res = mutableListOf<QueryComponent>()
var inQuotes = false
val queuedRawText = StringBuilder()
val queuedText = mutableListOf<TextComponent>()
var namespace: Namespace? = null
var nextIsExcluded = false
var nextIsExact = false
fun flushText() {
if (queuedRawText.isNotEmpty()) {
queuedText += StringTextComponent(queuedRawText.toString())
queuedRawText.setLength(0)
}
}
fun flushToText() = Text().apply {
components += queuedText
queuedText.clear()
}
fun flushAll() {
flushText()
if (queuedText.isNotEmpty() || namespace != null) {
val component = namespace?.apply {
tag = flushToText()
namespace = null
} ?: flushToText()
component.excluded = nextIsExcluded
component.exact = nextIsExact
res += component
}
}
query.lowercase(Locale.getDefault()).forEach { char ->
if (char == '"') {
inQuotes = !inQuotes
} else if (enableWildcard && (char == '?' || char == '_')) {
flushText()
queuedText.add(SingleWildcard(char.toString()))
} else if (enableWildcard && (char == '*' || char == '%')) {
flushText()
queuedText.add(MultiWildcard(char.toString()))
} else if (char == '-' && !inQuotes && (queuedRawText.isBlank() || queuedRawText.last() == ' ')) {
nextIsExcluded = true
} else if (char == '$') {
nextIsExact = true
} else if (char == ':') {
flushText()
var flushed = flushToText().rawTextOnly()
// Map tag aliases
flushed = when (flushed) {
"a" -> "artist"
"c", "char" -> "character"
"f" -> "female"
"g", "creator", "circle" -> "group"
"l", "lang" -> "language"
"m" -> "male"
"p", "series" -> "parody"
"r" -> "reclass"
else -> flushed
}
namespace = Namespace(flushed, null)
} else if (char == ' ' && !inQuotes) {
flushAll()
} else {
queuedRawText.append(char)
}
}
flushAll()
res
}
companion object {
private const val COL_MANGA_ID = "cmid"
fun escapeLike(string: String): String {
return string.replace("\\", "\\\\")
.replace("_", "\\_")
.replace("%", "\\%")
}
}
}