Merge branch 'palaks-1-new-search-query-proposal' into feature/search_query
commit
a4827d1b7d
@ -0,0 +1,84 @@
|
|||||||
|
package org.koitharu.kotatsu.parsers.model.search
|
||||||
|
|
||||||
|
import androidx.collection.ArrayMap
|
||||||
|
import androidx.collection.ArraySet
|
||||||
|
import org.koitharu.kotatsu.parsers.model.SortOrder
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Represents a search query for filtering and sorting manga search results.
|
||||||
|
* This class is immutable and must be constructed using the [Builder].
|
||||||
|
*
|
||||||
|
* @property criteria The set of search criteria applied to the query.
|
||||||
|
* @property order The sorting order for the results (optional).
|
||||||
|
* @property offset The offset number for paginated search results (optional).
|
||||||
|
*/
|
||||||
|
|
||||||
|
@ConsistentCopyVisibility
|
||||||
|
public data class MangaSearchQuery private constructor(
|
||||||
|
@JvmField public val criteria: Set<QueryCriteria<*>>,
|
||||||
|
@JvmField public val order: SortOrder?,
|
||||||
|
@JvmField public val offset: Int,
|
||||||
|
@JvmField public val skipValidation: Boolean,
|
||||||
|
) {
|
||||||
|
|
||||||
|
public fun newBuilder(): Builder = Builder(this)
|
||||||
|
|
||||||
|
public class Builder {
|
||||||
|
|
||||||
|
private val criteria = ArraySet<QueryCriteria<*>>()
|
||||||
|
private var order: SortOrder? = null
|
||||||
|
private var offset: Int = 0
|
||||||
|
private var skipValidation: Boolean = false
|
||||||
|
|
||||||
|
public constructor()
|
||||||
|
|
||||||
|
public constructor(query: MangaSearchQuery) : this() {
|
||||||
|
criteria.addAll(query.criteria)
|
||||||
|
order = query.order
|
||||||
|
offset = query.offset
|
||||||
|
}
|
||||||
|
|
||||||
|
public fun criterion(criterion: QueryCriteria<*>): Builder = apply { criteria.add(criterion) }
|
||||||
|
|
||||||
|
public fun order(order: SortOrder?): Builder = apply { this.order = order }
|
||||||
|
|
||||||
|
public fun offset(offset: Int): Builder = apply { this.offset = offset }
|
||||||
|
|
||||||
|
public fun skipValidation(skip: Boolean): Builder = apply { this.skipValidation = skip }
|
||||||
|
|
||||||
|
@Throws(IllegalArgumentException::class)
|
||||||
|
public fun build(): MangaSearchQuery {
|
||||||
|
return MangaSearchQuery(deduplicateCriteria(criteria), order, offset, skipValidation)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun deduplicateCriteria(criteria: Set<QueryCriteria<*>>): Set<QueryCriteria<*>> {
|
||||||
|
val uniqueCriteria =
|
||||||
|
ArrayMap<Pair<SearchableField, Class<out QueryCriteria<*>>>, QueryCriteria<*>>(criteria.size)
|
||||||
|
|
||||||
|
for (criterion in criteria) {
|
||||||
|
val key = criterion.field to criterion::class.java
|
||||||
|
val existing = uniqueCriteria[key]
|
||||||
|
|
||||||
|
when {
|
||||||
|
existing == null -> uniqueCriteria[key] = criterion
|
||||||
|
|
||||||
|
existing is QueryCriteria.Include<*> && criterion is QueryCriteria.Include<*> -> {
|
||||||
|
uniqueCriteria[key] =
|
||||||
|
QueryCriteria.Include(criterion.field, existing.values union criterion.values)
|
||||||
|
}
|
||||||
|
|
||||||
|
existing is QueryCriteria.Exclude<*> && criterion is QueryCriteria.Exclude<*> -> {
|
||||||
|
uniqueCriteria[key] =
|
||||||
|
QueryCriteria.Exclude(criterion.field, existing.values union criterion.values)
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> throw IllegalArgumentException(
|
||||||
|
"Match and Range have only one criterion per type, but found duplicates for: ${criterion.field} in ${criterion::class.simpleName}",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return uniqueCriteria.values.toSet()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,51 @@
|
|||||||
|
package org.koitharu.kotatsu.parsers.model.search
|
||||||
|
|
||||||
|
import androidx.collection.ArraySet
|
||||||
|
import org.koitharu.kotatsu.parsers.InternalParsersApi
|
||||||
|
import org.koitharu.kotatsu.parsers.model.search.QueryCriteria.*
|
||||||
|
import org.koitharu.kotatsu.parsers.util.mapToSet
|
||||||
|
|
||||||
|
@ExposedCopyVisibility
|
||||||
|
public data class MangaSearchQueryCapabilities internal constructor(
|
||||||
|
val capabilities: Set<SearchCapability>,
|
||||||
|
) {
|
||||||
|
|
||||||
|
public constructor(vararg capabilities: SearchCapability) : this(ArraySet(capabilities))
|
||||||
|
|
||||||
|
@InternalParsersApi
|
||||||
|
public fun validate(query: MangaSearchQuery) {
|
||||||
|
val strictFields = capabilities.filter { !it.otherCriteria }.mapToSet { it.field }
|
||||||
|
val usedStrictFields = query.criteria.mapToSet { it.field }.intersect(strictFields)
|
||||||
|
|
||||||
|
if (usedStrictFields.isNotEmpty() && query.criteria.size > 1) {
|
||||||
|
throw IllegalArgumentException(
|
||||||
|
"Query contains multiple criteria, but at least one field (${usedStrictFields.joinToString()}) does not support multiple criteria.",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
for (criterion in query.criteria) {
|
||||||
|
val capability = capabilities.find { it.field == criterion.field }
|
||||||
|
?: throw IllegalArgumentException("Unsupported search field: ${criterion.field}")
|
||||||
|
|
||||||
|
if (criterion::class !in capability.criteriaTypes) {
|
||||||
|
throw IllegalArgumentException(
|
||||||
|
"Unsupported search criterion: ${criterion::class.simpleName} for field ${criterion.field}",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure single value per criterion if supportMultiValue is false
|
||||||
|
if (!capability.multiValue) {
|
||||||
|
when (criterion) {
|
||||||
|
is Include<*> -> if (criterion.values.size > 1)
|
||||||
|
throw IllegalArgumentException("Multiple values are not allowed for field ${criterion.field}")
|
||||||
|
|
||||||
|
is Exclude<*> -> if (criterion.values.size > 1)
|
||||||
|
throw IllegalArgumentException("Multiple values are not allowed for field ${criterion.field}")
|
||||||
|
|
||||||
|
is Range<*> -> {} // Range is always valid (from, to)
|
||||||
|
is Match<*> -> {} // Match always has a single value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,59 @@
|
|||||||
|
package org.koitharu.kotatsu.parsers.model.search
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Represents a generic search criterion used for filtering manga search results.
|
||||||
|
* Each criterion applies a specific condition to a [SearchableField] and operates on values of type [T].
|
||||||
|
*
|
||||||
|
* @param T The type of value associated with the search criterion.
|
||||||
|
* @property field The field to which this search criterion applies.
|
||||||
|
*/
|
||||||
|
public sealed interface QueryCriteria<T> {
|
||||||
|
|
||||||
|
public val field: SearchableField
|
||||||
|
|
||||||
|
override fun equals(other: Any?): Boolean
|
||||||
|
|
||||||
|
override fun hashCode(): Int
|
||||||
|
|
||||||
|
public data class Include<T : Any>(
|
||||||
|
public override val field: SearchableField,
|
||||||
|
@JvmField public val values: Set<T>,
|
||||||
|
) : QueryCriteria<T> {
|
||||||
|
|
||||||
|
init {
|
||||||
|
check(values.all { x -> field.type.isInstance(x) })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public data class Exclude<T : Any>(
|
||||||
|
public override val field: SearchableField,
|
||||||
|
@JvmField public val values: Set<T>,
|
||||||
|
) : QueryCriteria<T> {
|
||||||
|
|
||||||
|
init {
|
||||||
|
check(values.all { x -> field.type.isInstance(x) })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public data class Range<T : Comparable<T>>(
|
||||||
|
public override val field: SearchableField,
|
||||||
|
@JvmField public val from: T,
|
||||||
|
@JvmField public val to: T,
|
||||||
|
) : QueryCriteria<T> {
|
||||||
|
|
||||||
|
init {
|
||||||
|
check(field.type.isInstance(from))
|
||||||
|
check(field.type.isInstance(to))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public data class Match<T : Any>(
|
||||||
|
public override val field: SearchableField,
|
||||||
|
@JvmField public val value: T,
|
||||||
|
) : QueryCriteria<T> {
|
||||||
|
|
||||||
|
init {
|
||||||
|
check(field.type.isInstance(value))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,10 @@
|
|||||||
|
package org.koitharu.kotatsu.parsers.model.search
|
||||||
|
|
||||||
|
import kotlin.reflect.KClass
|
||||||
|
|
||||||
|
public data class SearchCapability (
|
||||||
|
@JvmField public val field: SearchableField,
|
||||||
|
@JvmField public val criteriaTypes: Set<KClass<out QueryCriteria<*>>>,
|
||||||
|
@JvmField public val multiValue: Boolean,
|
||||||
|
@JvmField public val otherCriteria: Boolean,
|
||||||
|
)
|
||||||
@ -0,0 +1,23 @@
|
|||||||
|
package org.koitharu.kotatsu.parsers.model.search
|
||||||
|
|
||||||
|
import org.koitharu.kotatsu.parsers.model.*
|
||||||
|
import java.util.*
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Represents the various fields that can be used for searching manga.
|
||||||
|
* Each field is associated with a specific data type that defines its expected values.
|
||||||
|
*
|
||||||
|
* @property type The Java class representing the expected type of values for this field.
|
||||||
|
*/
|
||||||
|
public enum class SearchableField(public val type: Class<*>) {
|
||||||
|
TITLE_NAME(String::class.java),
|
||||||
|
TAG(MangaTag::class.java),
|
||||||
|
AUTHOR(MangaTag::class.java),
|
||||||
|
LANGUAGE(Locale::class.java),
|
||||||
|
ORIGINAL_LANGUAGE(Locale::class.java),
|
||||||
|
STATE(MangaState::class.java),
|
||||||
|
CONTENT_TYPE(ContentType::class.java),
|
||||||
|
CONTENT_RATING(ContentRating::class.java),
|
||||||
|
DEMOGRAPHIC(Demographic::class.java),
|
||||||
|
PUBLICATION_YEAR(Int::class.java);
|
||||||
|
}
|
||||||
@ -0,0 +1,244 @@
|
|||||||
|
package org.koitharu.kotatsu.parsers.util
|
||||||
|
|
||||||
|
import org.koitharu.kotatsu.parsers.InternalParsersApi
|
||||||
|
import org.koitharu.kotatsu.parsers.model.MangaListFilter
|
||||||
|
import org.koitharu.kotatsu.parsers.model.MangaListFilterCapabilities
|
||||||
|
import org.koitharu.kotatsu.parsers.model.SortOrder
|
||||||
|
import org.koitharu.kotatsu.parsers.model.YEAR_UNKNOWN
|
||||||
|
import org.koitharu.kotatsu.parsers.model.search.MangaSearchQuery
|
||||||
|
import org.koitharu.kotatsu.parsers.model.search.MangaSearchQueryCapabilities
|
||||||
|
import org.koitharu.kotatsu.parsers.model.search.QueryCriteria
|
||||||
|
import org.koitharu.kotatsu.parsers.model.search.QueryCriteria.*
|
||||||
|
import org.koitharu.kotatsu.parsers.model.search.SearchCapability
|
||||||
|
import org.koitharu.kotatsu.parsers.model.search.SearchableField.*
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Converts a [MangaListFilter] into a [MangaSearchQuery].
|
||||||
|
*
|
||||||
|
* This function iterates through the filter attributes in [MangaListFilter] and creates corresponding
|
||||||
|
* search criteria in a [MangaSearchQuery.Builder].
|
||||||
|
*
|
||||||
|
* @param filter The [MangaListFilter] to convert.
|
||||||
|
* @return A [MangaSearchQuery] constructed based on the given [filter].
|
||||||
|
*/
|
||||||
|
@InternalParsersApi
|
||||||
|
public fun convertToMangaSearchQuery(offset: Int, sortOrder: SortOrder, filter: MangaListFilter): MangaSearchQuery {
|
||||||
|
return MangaSearchQuery.Builder().apply {
|
||||||
|
offset(offset)
|
||||||
|
order(sortOrder)
|
||||||
|
if (filter.tags.isNotEmpty()) criterion(Include(TAG, filter.tags))
|
||||||
|
if (filter.tagsExclude.isNotEmpty()) criterion(Exclude(TAG, filter.tagsExclude))
|
||||||
|
if (filter.states.isNotEmpty()) criterion(Include(STATE, filter.states))
|
||||||
|
if (filter.types.isNotEmpty()) criterion(Include(CONTENT_TYPE, filter.types))
|
||||||
|
if (filter.contentRating.isNotEmpty()) criterion(Include(CONTENT_RATING, filter.contentRating))
|
||||||
|
if (filter.demographics.isNotEmpty()) criterion(Include(DEMOGRAPHIC, filter.demographics))
|
||||||
|
if (validateYear(filter.yearFrom) || validateYear(filter.yearTo)) {
|
||||||
|
criterion(QueryCriteria.Range(PUBLICATION_YEAR, filter.yearFrom, filter.yearTo))
|
||||||
|
}
|
||||||
|
if (validateYear(filter.year)) {
|
||||||
|
criterion(Match(PUBLICATION_YEAR, filter.year))
|
||||||
|
}
|
||||||
|
filter.locale?.let {
|
||||||
|
criterion(Include(LANGUAGE, setOf(it)))
|
||||||
|
}
|
||||||
|
filter.originalLocale?.let {
|
||||||
|
criterion(Include(ORIGINAL_LANGUAGE, setOf(it)))
|
||||||
|
}
|
||||||
|
filter.query?.takeIf { it.isNotBlank() }?.let {
|
||||||
|
criterion(Match(TITLE_NAME, it))
|
||||||
|
}
|
||||||
|
}.build()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Converts a {@link MangaSearchQuery} into a {@link MangaListFilter}.
|
||||||
|
* <p>
|
||||||
|
* This method iterates through the search criteria defined in the provided {@code searchQuery}
|
||||||
|
* and applies them to a {@link MangaListFilter.Builder}. The criteria are processed based on
|
||||||
|
* their types, such as inclusion, exclusion, equality checks, range filtering, and pattern matching.
|
||||||
|
* </p>
|
||||||
|
* <p>
|
||||||
|
* Supported criteria:
|
||||||
|
* <ul>
|
||||||
|
* <li>{@link QueryCriteria.Include} - Adds tags, states, content types, content ratings, demographics, and languages.</li>
|
||||||
|
* <li>{@link QueryCriteria.Exclude} - Excludes tags.</li>
|
||||||
|
* <li>{@link QueryCriteria.Equals} - Sets specific values like publication year.</li>
|
||||||
|
* <li>{@link QueryCriteria.Between} - Sets a range of values like publication year range.</li>
|
||||||
|
* <li>{@link QueryCriteria.Match} - Adds a search pattern for the title name.</li>
|
||||||
|
* </ul>
|
||||||
|
* </p>
|
||||||
|
* <p>
|
||||||
|
* If an unsupported field is encountered, an {@link UnsupportedOperationException} is thrown.
|
||||||
|
* </p>
|
||||||
|
*
|
||||||
|
* @param searchQuery The {@link MangaSearchQuery} to convert.
|
||||||
|
* @return A {@link MangaListFilter} constructed based on the given {@code searchQuery}.
|
||||||
|
* @throws UnsupportedOperationException If the search criteria contain unsupported fields.
|
||||||
|
*/
|
||||||
|
@InternalParsersApi
|
||||||
|
public fun convertToMangaListFilter(searchQuery: MangaSearchQuery): MangaListFilter {
|
||||||
|
return MangaListFilter.Builder().apply {
|
||||||
|
for (criterion in searchQuery.criteria) {
|
||||||
|
when (criterion) {
|
||||||
|
is Include<*> -> handleInclude(this, criterion)
|
||||||
|
is Exclude<*> -> handleExclude(this, criterion)
|
||||||
|
is Range<*> -> handleBetween(this, criterion)
|
||||||
|
is Match<*> -> handleMatch(this, criterion)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}.build()
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun MangaListFilterCapabilities.toMangaSearchQueryCapabilities(): MangaSearchQueryCapabilities =
|
||||||
|
MangaSearchQueryCapabilities(
|
||||||
|
capabilities = setOfNotNull(
|
||||||
|
isMultipleTagsSupported.takeIf { it }?.let {
|
||||||
|
SearchCapability(
|
||||||
|
field = TAG, criteriaTypes = setOf(Include::class), multiValue = true, otherCriteria = true,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
isTagsExclusionSupported.takeIf { it }?.let {
|
||||||
|
SearchCapability(
|
||||||
|
field = TAG, criteriaTypes = setOf(Exclude::class), multiValue = true, otherCriteria = true,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
isSearchSupported.takeIf { it }?.let {
|
||||||
|
SearchCapability(
|
||||||
|
field = TITLE_NAME,
|
||||||
|
criteriaTypes = setOf(Match::class),
|
||||||
|
multiValue = false,
|
||||||
|
otherCriteria = false,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
isSearchWithFiltersSupported.takeIf { it }?.let {
|
||||||
|
SearchCapability(
|
||||||
|
field = TITLE_NAME,
|
||||||
|
criteriaTypes = setOf(Match::class),
|
||||||
|
multiValue = false,
|
||||||
|
otherCriteria = true,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
isYearSupported.takeIf { it }?.let {
|
||||||
|
SearchCapability(
|
||||||
|
field = PUBLICATION_YEAR,
|
||||||
|
criteriaTypes = setOf(Match::class),
|
||||||
|
multiValue = false,
|
||||||
|
otherCriteria = true,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
isYearRangeSupported.takeIf { it }?.let {
|
||||||
|
SearchCapability(
|
||||||
|
field = PUBLICATION_YEAR,
|
||||||
|
criteriaTypes = setOf(Range::class),
|
||||||
|
multiValue = false,
|
||||||
|
otherCriteria = true,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
isOriginalLocaleSupported.takeIf { it }?.let {
|
||||||
|
SearchCapability(
|
||||||
|
field = ORIGINAL_LANGUAGE,
|
||||||
|
criteriaTypes = setOf(Include::class),
|
||||||
|
multiValue = true,
|
||||||
|
otherCriteria = true,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
SearchCapability(
|
||||||
|
field = LANGUAGE,
|
||||||
|
criteriaTypes = setOf(Include::class),
|
||||||
|
multiValue = true,
|
||||||
|
otherCriteria = true,
|
||||||
|
),
|
||||||
|
SearchCapability(
|
||||||
|
field = STATE, criteriaTypes = setOf(Include::class), multiValue = true, otherCriteria = true,
|
||||||
|
),
|
||||||
|
SearchCapability(
|
||||||
|
field = CONTENT_TYPE,
|
||||||
|
criteriaTypes = setOf(Include::class),
|
||||||
|
multiValue = true,
|
||||||
|
otherCriteria = true,
|
||||||
|
),
|
||||||
|
SearchCapability(
|
||||||
|
field = CONTENT_RATING,
|
||||||
|
criteriaTypes = setOf(Include::class),
|
||||||
|
multiValue = true,
|
||||||
|
otherCriteria = true,
|
||||||
|
),
|
||||||
|
SearchCapability(
|
||||||
|
field = DEMOGRAPHIC,
|
||||||
|
criteriaTypes = setOf(Include::class),
|
||||||
|
multiValue = true,
|
||||||
|
otherCriteria = true,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun handleInclude(builder: MangaListFilter.Builder, criterion: Include<*>) {
|
||||||
|
val type = criterion.field.type
|
||||||
|
|
||||||
|
when (criterion.field) {
|
||||||
|
TAG -> builder.addTags(filterValues(criterion, type))
|
||||||
|
STATE -> builder.addStates(filterValues(criterion, type))
|
||||||
|
CONTENT_TYPE -> builder.addTypes(filterValues(criterion, type))
|
||||||
|
CONTENT_RATING -> builder.addContentRatings(filterValues(criterion, type))
|
||||||
|
DEMOGRAPHIC -> builder.addDemographics(filterValues(criterion, type))
|
||||||
|
LANGUAGE -> builder.locale(getFirstValue(criterion, type))
|
||||||
|
ORIGINAL_LANGUAGE -> builder.originalLocale(getFirstValue(criterion, type))
|
||||||
|
else -> throw IllegalArgumentException("Unsupported field for Include criterion: ${criterion.field}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun handleExclude(builder: MangaListFilter.Builder, criterion: Exclude<*>) {
|
||||||
|
val type = criterion.field.type
|
||||||
|
|
||||||
|
when (criterion.field) {
|
||||||
|
TAG -> builder.excludeTags(filterValues(criterion, type))
|
||||||
|
else -> throw IllegalArgumentException("Unsupported field for Exclude criterion: ${criterion.field}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun handleBetween(builder: MangaListFilter.Builder, criterion: Range<*>) {
|
||||||
|
val type = criterion.field.type
|
||||||
|
|
||||||
|
when (criterion.field) {
|
||||||
|
PUBLICATION_YEAR -> {
|
||||||
|
builder.yearFrom(getValue(criterion.from, type, YEAR_UNKNOWN))
|
||||||
|
builder.yearTo(getValue(criterion.to, type, YEAR_UNKNOWN))
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> throw IllegalArgumentException("Unsupported field for Between criterion: ${criterion.field}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun handleMatch(builder: MangaListFilter.Builder, criterion: Match<*>) {
|
||||||
|
val type = criterion.field.type
|
||||||
|
|
||||||
|
when (criterion.field) {
|
||||||
|
TITLE_NAME -> builder.query(getValue(criterion.value, type, ""))
|
||||||
|
PUBLICATION_YEAR -> builder.year(getValue(criterion.value, type, YEAR_UNKNOWN))
|
||||||
|
else -> throw IllegalArgumentException("Unsupported field for Match criterion: ${criterion.field}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Suppress("UNCHECKED_CAST")
|
||||||
|
private fun <T> filterValues(criterion: Include<*>, type: Class<*>): List<T> {
|
||||||
|
return criterion.values.filter { type.isInstance(it) } as List<T>
|
||||||
|
}
|
||||||
|
|
||||||
|
@Suppress("UNCHECKED_CAST")
|
||||||
|
private fun <T> filterValues(criterion: Exclude<*>, type: Class<*>): List<T> {
|
||||||
|
return criterion.values.filter { type.isInstance(it) } as List<T>
|
||||||
|
}
|
||||||
|
|
||||||
|
@Suppress("UNCHECKED_CAST")
|
||||||
|
private fun <T> getFirstValue(criterion: Include<*>, type: Class<*>): T? {
|
||||||
|
return criterion.values.firstOrNull { type.isInstance(it) } as? T
|
||||||
|
}
|
||||||
|
|
||||||
|
@Suppress("UNCHECKED_CAST")
|
||||||
|
private fun <T> getValue(value: Any?, type: Class<*>, default: T): T {
|
||||||
|
val isCompatibleIntType = (type == Int::class.java && Integer::class.isInstance(value))
|
||||||
|
|
||||||
|
return if (type.isInstance(value) || isCompatibleIntType) value as T else default
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun validateYear(year: Int) = year != YEAR_UNKNOWN
|
||||||
@ -0,0 +1,72 @@
|
|||||||
|
package org.koitharu.kotatsu.parsers.model.search
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Assertions.assertDoesNotThrow
|
||||||
|
import org.junit.jupiter.api.Assertions.assertThrows
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
import org.koitharu.kotatsu.parsers.model.MangaParserSource
|
||||||
|
import org.koitharu.kotatsu.parsers.model.MangaState
|
||||||
|
import org.koitharu.kotatsu.parsers.model.MangaTag
|
||||||
|
import org.koitharu.kotatsu.parsers.model.search.QueryCriteria.*
|
||||||
|
import org.koitharu.kotatsu.parsers.model.search.SearchableField.*
|
||||||
|
import java.util.*
|
||||||
|
|
||||||
|
class MangaSearchQueryCapabilitiesTest {
|
||||||
|
|
||||||
|
private val capabilities = MangaSearchQueryCapabilities(
|
||||||
|
capabilities = setOf(
|
||||||
|
SearchCapability(TITLE_NAME, setOf(Match::class), multiValue = false, otherCriteria = false),
|
||||||
|
SearchCapability(TAG, setOf(Include::class, Exclude::class), multiValue = true, otherCriteria = true),
|
||||||
|
SearchCapability(PUBLICATION_YEAR, setOf(Range::class), multiValue = false, otherCriteria = true),
|
||||||
|
SearchCapability(STATE, setOf(Include::class), multiValue = false, otherCriteria = true),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun validateValidSingleCriterionQuery() {
|
||||||
|
val query = MangaSearchQuery.Builder()
|
||||||
|
.criterion(Match(TITLE_NAME, "title"))
|
||||||
|
.build()
|
||||||
|
|
||||||
|
assertDoesNotThrow { capabilities.validate(query) }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun validateUnsupportedFieldThrowsException() {
|
||||||
|
val query = MangaSearchQuery.Builder()
|
||||||
|
.criterion(Include(ORIGINAL_LANGUAGE, setOf(Locale.ENGLISH)))
|
||||||
|
.build()
|
||||||
|
|
||||||
|
assertThrows(IllegalArgumentException::class.java) { capabilities.validate(query) }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun validateUnsupportedMultiValueThrowsException() {
|
||||||
|
val query = MangaSearchQuery.Builder()
|
||||||
|
.criterion(Include(STATE, setOf(MangaState.ONGOING, MangaState.FINISHED)))
|
||||||
|
.build()
|
||||||
|
|
||||||
|
assertThrows(IllegalArgumentException::class.java) { capabilities.validate(query) }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun validateMultipleCriteriaWithOtherCriteriaAllowed() {
|
||||||
|
val query = MangaSearchQuery.Builder()
|
||||||
|
.criterion(Include(TAG, setOf(buildTag("tag1"), buildTag("tag2"))))
|
||||||
|
.criterion(Exclude(TAG, setOf(buildTag("tag3"))))
|
||||||
|
.build()
|
||||||
|
|
||||||
|
assertDoesNotThrow { capabilities.validate(query) }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun validateMultipleCriteriaWithStrictCapabilityThrowsException() {
|
||||||
|
val query = MangaSearchQuery.Builder()
|
||||||
|
.criterion(Match(TITLE_NAME, "title"))
|
||||||
|
.criterion(Range(PUBLICATION_YEAR, 1990, 2000))
|
||||||
|
.build()
|
||||||
|
|
||||||
|
assertThrows(IllegalArgumentException::class.java) { capabilities.validate(query) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun buildTag(name: String) = MangaTag(title = name, key = "${name}Key", source = MangaParserSource.DUMMY)
|
||||||
|
}
|
||||||
@ -0,0 +1,77 @@
|
|||||||
|
package org.koitharu.kotatsu.parsers.util
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Assertions.assertEquals
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
import org.koitharu.kotatsu.parsers.model.*
|
||||||
|
import org.koitharu.kotatsu.parsers.model.ContentType.MANGA
|
||||||
|
import org.koitharu.kotatsu.parsers.model.ContentType.MANHUA
|
||||||
|
import org.koitharu.kotatsu.parsers.model.Demographic.SEINEN
|
||||||
|
import org.koitharu.kotatsu.parsers.model.search.MangaSearchQuery
|
||||||
|
import org.koitharu.kotatsu.parsers.model.search.QueryCriteria.*
|
||||||
|
import org.koitharu.kotatsu.parsers.model.search.SearchableField.*
|
||||||
|
import java.util.*
|
||||||
|
|
||||||
|
class ListFilterToSearchQueryConverterTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun convertToMangaSearchQueryTest() {
|
||||||
|
val tags = setOf(buildMangaTag("tag1"), buildMangaTag("tag2"))
|
||||||
|
val excludedTags = setOf(buildMangaTag("exclude_tag"))
|
||||||
|
val states = setOf(MangaState.ONGOING)
|
||||||
|
val contentRatings = setOf(ContentRating.SAFE)
|
||||||
|
val contentTypes = setOf(MANGA, MANHUA)
|
||||||
|
val demographics = setOf(SEINEN)
|
||||||
|
|
||||||
|
val filter = MangaListFilter(
|
||||||
|
query = "title_name",
|
||||||
|
tags = tags,
|
||||||
|
tagsExclude = excludedTags,
|
||||||
|
locale = Locale.ENGLISH,
|
||||||
|
originalLocale = Locale.JAPANESE,
|
||||||
|
states = states,
|
||||||
|
contentRating = contentRatings,
|
||||||
|
types = contentTypes,
|
||||||
|
demographics = demographics,
|
||||||
|
year = 2020,
|
||||||
|
yearFrom = 1997,
|
||||||
|
yearTo = 2024,
|
||||||
|
)
|
||||||
|
|
||||||
|
val searchQuery = convertToMangaSearchQuery(0, SortOrder.NEWEST, filter)
|
||||||
|
|
||||||
|
val expectedQuery = MangaSearchQuery.Builder()
|
||||||
|
.offset(0)
|
||||||
|
.order(SortOrder.NEWEST)
|
||||||
|
.criterion(Match(TITLE_NAME, "title_name"))
|
||||||
|
.criterion(Include(TAG, tags))
|
||||||
|
.criterion(Exclude(TAG, excludedTags))
|
||||||
|
.criterion(Include(LANGUAGE, setOf(Locale.ENGLISH)))
|
||||||
|
.criterion(Include(ORIGINAL_LANGUAGE, setOf(Locale.JAPANESE)))
|
||||||
|
.criterion(Include(STATE, states))
|
||||||
|
.criterion(Include(CONTENT_RATING, contentRatings))
|
||||||
|
.criterion(Include(CONTENT_TYPE, contentTypes))
|
||||||
|
.criterion(Include(DEMOGRAPHIC, demographics))
|
||||||
|
.criterion(Range(PUBLICATION_YEAR, 1997, 2024))
|
||||||
|
.criterion(Match(PUBLICATION_YEAR, 2020))
|
||||||
|
.build()
|
||||||
|
|
||||||
|
assertEquals(expectedQuery, searchQuery)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun convertToMangaSearchQueryWithEmptyFieldsTest() {
|
||||||
|
val filter = MangaListFilter()
|
||||||
|
|
||||||
|
val searchQuery = convertToMangaSearchQuery(0, SortOrder.NEWEST, filter)
|
||||||
|
|
||||||
|
assertEquals(MangaSearchQuery.Builder().offset(0).order(SortOrder.NEWEST).build(), searchQuery)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun buildMangaTag(name: String): MangaTag {
|
||||||
|
return MangaTag(
|
||||||
|
key = "${name}Key",
|
||||||
|
title = name,
|
||||||
|
source = MangaParserSource.DUMMY,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,94 @@
|
|||||||
|
package org.koitharu.kotatsu.parsers.util
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Assertions.assertEquals
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
import org.junit.jupiter.api.assertThrows
|
||||||
|
import org.koitharu.kotatsu.parsers.model.ContentRating
|
||||||
|
import org.koitharu.kotatsu.parsers.model.ContentType.MANGA
|
||||||
|
import org.koitharu.kotatsu.parsers.model.ContentType.MANHUA
|
||||||
|
import org.koitharu.kotatsu.parsers.model.Demographic.SEINEN
|
||||||
|
import org.koitharu.kotatsu.parsers.model.MangaParserSource
|
||||||
|
import org.koitharu.kotatsu.parsers.model.MangaState
|
||||||
|
import org.koitharu.kotatsu.parsers.model.MangaTag
|
||||||
|
import org.koitharu.kotatsu.parsers.model.search.MangaSearchQuery
|
||||||
|
import org.koitharu.kotatsu.parsers.model.search.QueryCriteria.*
|
||||||
|
import org.koitharu.kotatsu.parsers.model.search.SearchableField.*
|
||||||
|
import java.util.*
|
||||||
|
|
||||||
|
class ConvertToMangaListFilterTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun convertToMangaListFilterTest() {
|
||||||
|
val tags = setOf(buildMangaTag("tag1"), buildMangaTag("tag2"))
|
||||||
|
val excludedTags = setOf(buildMangaTag("exclude_tag"))
|
||||||
|
val states = setOf(MangaState.ONGOING)
|
||||||
|
val contentRatings = setOf(ContentRating.SAFE)
|
||||||
|
val contentTypes = setOf(MANGA, MANHUA)
|
||||||
|
val demographics = setOf(SEINEN)
|
||||||
|
|
||||||
|
val query = MangaSearchQuery.Builder()
|
||||||
|
.criterion(Match(TITLE_NAME, "title_name"))
|
||||||
|
.criterion(Include(TAG, tags))
|
||||||
|
.criterion(Exclude(TAG, excludedTags))
|
||||||
|
.criterion(Include(LANGUAGE, setOf(Locale.ENGLISH)))
|
||||||
|
.criterion(Include(ORIGINAL_LANGUAGE, setOf(Locale.JAPANESE)))
|
||||||
|
.criterion(Include(STATE, states))
|
||||||
|
.criterion(Include(CONTENT_RATING, contentRatings))
|
||||||
|
.criterion(Include(CONTENT_TYPE, contentTypes))
|
||||||
|
.criterion(Include(DEMOGRAPHIC, demographics))
|
||||||
|
.criterion(Range(PUBLICATION_YEAR, 1997, 2024))
|
||||||
|
.criterion(Match(PUBLICATION_YEAR, 2020))
|
||||||
|
.build()
|
||||||
|
|
||||||
|
val listFilter = convertToMangaListFilter(query)
|
||||||
|
|
||||||
|
assertEquals(listFilter.query, "title_name")
|
||||||
|
assertEquals(listFilter.tags, tags)
|
||||||
|
assertEquals(listFilter.tagsExclude, excludedTags)
|
||||||
|
assertEquals(listFilter.locale, Locale.ENGLISH)
|
||||||
|
assertEquals(listFilter.originalLocale, Locale.JAPANESE)
|
||||||
|
assertEquals(listFilter.states, states)
|
||||||
|
assertEquals(listFilter.contentRating, contentRatings)
|
||||||
|
assertEquals(listFilter.types, contentTypes)
|
||||||
|
assertEquals(listFilter.demographics, demographics)
|
||||||
|
assertEquals(listFilter.year, 2020)
|
||||||
|
assertEquals(listFilter.yearFrom, 1997)
|
||||||
|
assertEquals(listFilter.yearTo, 2024)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun convertToMangaListFilterWithMultipleTagsIncludeTest() {
|
||||||
|
val tags1 = setOf(buildMangaTag("tag1"), buildMangaTag("tag2"))
|
||||||
|
val tags2 = setOf(buildMangaTag("tag3"), buildMangaTag("tag4"))
|
||||||
|
|
||||||
|
val query = MangaSearchQuery.Builder()
|
||||||
|
.criterion(Include(TAG, tags1))
|
||||||
|
.criterion(Include(TAG, tags2))
|
||||||
|
.build()
|
||||||
|
|
||||||
|
val listFilter = convertToMangaListFilter(query)
|
||||||
|
|
||||||
|
assertEquals(listFilter.tags, tags1 union tags2)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun convertToMangaListFilterWithUnsupportedFieldTest() {
|
||||||
|
val query = MangaSearchQuery.Builder()
|
||||||
|
.criterion(Include(AUTHOR, setOf(buildMangaTag("author"))))
|
||||||
|
.build()
|
||||||
|
|
||||||
|
val exception = assertThrows<IllegalArgumentException> {
|
||||||
|
convertToMangaListFilter(query)
|
||||||
|
}
|
||||||
|
|
||||||
|
assert(exception.message!!.contains("Unsupported field for Include criterion: AUTHOR"))
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun buildMangaTag(name: String): MangaTag {
|
||||||
|
return MangaTag(
|
||||||
|
key = "${name}Key",
|
||||||
|
title = name,
|
||||||
|
source = MangaParserSource.DUMMY,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue