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>() fun textToSubQueries( namespace: String?, component: Text? ): Pair>? { val maybeLenientComponent = component?.let { if (!it.exact) { it.asLenientTagQueries() } else { listOf(it.asQuery()) } } val componentTagQuery = maybeLenientComponent?.let { val params = mutableListOf() 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): Pair> { val wheres = mutableListOf() val whereParams = mutableListOf() val include = mutableListOf>>() val exclude = mutableListOf>>() 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() 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() var inQuotes = false val queuedRawText = StringBuilder() val queuedText = mutableListOf() 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("%", "\\%") } } }