New MangaSearchQuery implementation proposal
parent
8c966c3e23
commit
c0ea9cadd7
@ -0,0 +1,130 @@
|
|||||||
|
package org.koitharu.kotatsu.parsers.model.search
|
||||||
|
|
||||||
|
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).
|
||||||
|
*/
|
||||||
|
public class MangaSearchQuery private constructor(
|
||||||
|
@JvmField public val criteria: Set<QueryCriteria<*>> = emptySet(),
|
||||||
|
@JvmField public val order: SortOrder? = null,
|
||||||
|
@JvmField public val offset: Int? = null,
|
||||||
|
) {
|
||||||
|
|
||||||
|
override fun equals(other: Any?): Boolean {
|
||||||
|
if (this === other) return true
|
||||||
|
if (other !is MangaSearchQuery) return false
|
||||||
|
|
||||||
|
return criteria == other.criteria &&
|
||||||
|
order == other.order &&
|
||||||
|
offset == other.offset
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun hashCode(): Int {
|
||||||
|
var result = criteria.hashCode()
|
||||||
|
result = 31 * result + (order?.hashCode() ?: 0)
|
||||||
|
result = 31 * result + (offset ?: 0)
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
public companion object {
|
||||||
|
public fun builder(): Builder = Builder()
|
||||||
|
}
|
||||||
|
|
||||||
|
public class Builder {
|
||||||
|
private var criteria: MutableSet<QueryCriteria<*>> = mutableSetOf()
|
||||||
|
private var order: SortOrder? = null
|
||||||
|
private var offset: Int? = null
|
||||||
|
|
||||||
|
public fun copy(searchQuery: MangaSearchQuery): Builder = apply {
|
||||||
|
this.criteria = searchQuery.criteria as MutableSet<QueryCriteria<*>>
|
||||||
|
this.order = searchQuery.order
|
||||||
|
this.offset = searchQuery.offset
|
||||||
|
}
|
||||||
|
|
||||||
|
@Throws(IllegalArgumentException::class)
|
||||||
|
public fun criterion(criterion: QueryCriteria<*>): Builder = apply {
|
||||||
|
validateCriterion(criterion)
|
||||||
|
this.criteria.add(criterion)
|
||||||
|
}
|
||||||
|
|
||||||
|
public fun order(order: SortOrder?): Builder = apply { this.order = order }
|
||||||
|
|
||||||
|
public fun offset(offset: Int?): Builder = apply { this.offset = offset }
|
||||||
|
|
||||||
|
@Throws(IllegalArgumentException::class)
|
||||||
|
public fun build(): MangaSearchQuery {
|
||||||
|
return MangaSearchQuery(deduplicateCriteria(criteria), order, offset)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates the provided [QueryCriteria] to ensure type correctness.
|
||||||
|
*
|
||||||
|
* @param criterion The search criterion to validate.
|
||||||
|
* @throws IllegalArgumentException If the criterion type does not match the expected type.
|
||||||
|
*/
|
||||||
|
private fun validateCriterion(criterion: QueryCriteria<*>) {
|
||||||
|
try {
|
||||||
|
val expectedType = criterion.field.type
|
||||||
|
val actualType: Class<*>? = when (criterion) {
|
||||||
|
is QueryCriteria.Include<*> -> criterion.values.first().javaClass
|
||||||
|
is QueryCriteria.Exclude<*> -> criterion.values.first().javaClass
|
||||||
|
is QueryCriteria.Match<*> -> criterion.value.javaClass
|
||||||
|
is QueryCriteria.Range<*> -> {
|
||||||
|
if (criterion.from.javaClass != criterion.to.javaClass) {
|
||||||
|
throw IllegalArgumentException(
|
||||||
|
"Mismatched types for field '${criterion.field}'. 'from' and 'to' should have same types"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
criterion.from.javaClass
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val isCompatibleIntType = (expectedType == Int::class.java && actualType == Integer::class.java) ||
|
||||||
|
(expectedType == Integer::class.java && actualType == Int::class.java)
|
||||||
|
|
||||||
|
if (actualType != null && !expectedType.isAssignableFrom(actualType) && !isCompatibleIntType) {
|
||||||
|
throw IllegalArgumentException(
|
||||||
|
"Invalid type for ${criterion.field}. Expected: ${expectedType.simpleName}, but got: ${actualType.simpleName}"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} catch (e: NoSuchElementException) {
|
||||||
|
throw IllegalArgumentException(
|
||||||
|
"QueryCriteria values should not be empty"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun deduplicateCriteria(criteria: Set<QueryCriteria<*>>): Set<QueryCriteria<*>> {
|
||||||
|
val uniqueCriteria = mutableMapOf<Pair<SearchableField, Class<out QueryCriteria<*>>>, QueryCriteria<*>>()
|
||||||
|
|
||||||
|
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,105 @@
|
|||||||
|
package org.koitharu.kotatsu.parsers.model.search
|
||||||
|
|
||||||
|
import org.koitharu.kotatsu.parsers.InternalParsersApi
|
||||||
|
import org.koitharu.kotatsu.parsers.model.MangaListFilterCapabilities
|
||||||
|
import org.koitharu.kotatsu.parsers.model.search.QueryCriteria.*
|
||||||
|
import org.koitharu.kotatsu.parsers.model.search.SearchableField.*
|
||||||
|
|
||||||
|
public data class MangaSearchQueryCapabilities(
|
||||||
|
val capabilities: Set<SearchCapability> = emptySet(),
|
||||||
|
) {
|
||||||
|
public companion object {
|
||||||
|
@InternalParsersApi
|
||||||
|
public fun from(filterCapabilities: MangaListFilterCapabilities): MangaSearchQueryCapabilities {
|
||||||
|
return MangaSearchQueryCapabilities(
|
||||||
|
capabilities = setOfNotNull(
|
||||||
|
filterCapabilities.isMultipleTagsSupported.takeIf { it }?.let {
|
||||||
|
SearchCapability(
|
||||||
|
field = TAG, criteriaTypes = setOf(Include::class), multiValue = true, otherCriteria = true
|
||||||
|
)
|
||||||
|
},
|
||||||
|
filterCapabilities.isTagsExclusionSupported.takeIf { it }?.let {
|
||||||
|
SearchCapability(
|
||||||
|
field = TAG, criteriaTypes = setOf(Exclude::class), multiValue = true, otherCriteria = true
|
||||||
|
)
|
||||||
|
},
|
||||||
|
filterCapabilities.isSearchSupported.takeIf { it }?.let {
|
||||||
|
SearchCapability(
|
||||||
|
field = TITLE_NAME, criteriaTypes = setOf(Match::class), multiValue = false, otherCriteria = false
|
||||||
|
)
|
||||||
|
},
|
||||||
|
filterCapabilities.isSearchWithFiltersSupported.takeIf { it }?.let {
|
||||||
|
SearchCapability(
|
||||||
|
field = TITLE_NAME, criteriaTypes = setOf(Match::class), multiValue = false, otherCriteria = true
|
||||||
|
)
|
||||||
|
},
|
||||||
|
filterCapabilities.isYearSupported.takeIf { it }?.let {
|
||||||
|
SearchCapability(
|
||||||
|
field = PUBLICATION_YEAR, criteriaTypes = setOf(Match::class), multiValue = false, otherCriteria = true
|
||||||
|
)
|
||||||
|
},
|
||||||
|
filterCapabilities.isYearRangeSupported.takeIf { it }?.let {
|
||||||
|
SearchCapability(
|
||||||
|
field = PUBLICATION_YEAR, criteriaTypes = setOf(Range::class), multiValue = false, otherCriteria = true
|
||||||
|
)
|
||||||
|
},
|
||||||
|
filterCapabilities.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
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@InternalParsersApi
|
||||||
|
public fun validate(query: MangaSearchQuery) {
|
||||||
|
val strictFields = capabilities.filter { !it.otherCriteria }.map { it.field }.toSet()
|
||||||
|
val usedStrictFields = query.criteria.map { it.field }.toSet().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,63 @@
|
|||||||
|
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 class QueryCriteria<T>(
|
||||||
|
@JvmField public val field: SearchableField
|
||||||
|
) {
|
||||||
|
public class Include<T : Any>(field: SearchableField, @JvmField public val values: Set<T>) : QueryCriteria<T>(field) {
|
||||||
|
override fun equals(other: Any?): Boolean {
|
||||||
|
if (this === other) return true
|
||||||
|
if (other !is Include<*>) return false
|
||||||
|
return field == other.field && values == other.values
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun hashCode(): Int {
|
||||||
|
return 31 * field.hashCode() + values.hashCode()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public class Exclude<T : Any>(field: SearchableField, @JvmField public val values: Set<T>) : QueryCriteria<T>(field) {
|
||||||
|
override fun equals(other: Any?): Boolean {
|
||||||
|
if (this === other) return true
|
||||||
|
if (other !is Exclude<*>) return false
|
||||||
|
return field == other.field && values == other.values
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun hashCode(): Int {
|
||||||
|
return 31 * field.hashCode() + values.hashCode()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public class Range<T : Comparable<T>>(field: SearchableField, @JvmField public val from: T, @JvmField public val to: T) : QueryCriteria<T>(field) {
|
||||||
|
override fun equals(other: Any?): Boolean {
|
||||||
|
if (this === other) return true
|
||||||
|
if (other !is Range<*>) return false
|
||||||
|
return field == other.field && from == other.from && to == other.to
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun hashCode(): Int {
|
||||||
|
var result = field.hashCode()
|
||||||
|
result = 31 * result + from.hashCode()
|
||||||
|
result = 31 * result + to.hashCode()
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public class Match<T : Any>(field: SearchableField, @JvmField public val value: T) : QueryCriteria<T>(field) {
|
||||||
|
override fun equals(other: Any?): Boolean {
|
||||||
|
if (this === other) return true
|
||||||
|
if (other !is Match<*>) return false
|
||||||
|
return field == other.field && value == other.value
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun hashCode(): Int {
|
||||||
|
return 31 * field.hashCode() + value.hashCode()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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.Locale
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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,154 @@
|
|||||||
|
package org.koitharu.kotatsu.parsers.util
|
||||||
|
|
||||||
|
import org.koitharu.kotatsu.parsers.InternalParsersApi
|
||||||
|
import org.koitharu.kotatsu.parsers.model.*
|
||||||
|
import org.koitharu.kotatsu.parsers.model.search.MangaSearchQuery
|
||||||
|
import org.koitharu.kotatsu.parsers.model.search.QueryCriteria
|
||||||
|
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(QueryCriteria.Include(TAG, filter.tags))
|
||||||
|
if (filter.tagsExclude.isNotEmpty()) criterion(QueryCriteria.Exclude(TAG, filter.tagsExclude))
|
||||||
|
if (filter.states.isNotEmpty()) criterion(QueryCriteria.Include(STATE, filter.states))
|
||||||
|
if (filter.types.isNotEmpty()) criterion(QueryCriteria.Include(CONTENT_TYPE, filter.types))
|
||||||
|
if (filter.contentRating.isNotEmpty()) criterion(QueryCriteria.Include(CONTENT_RATING, filter.contentRating))
|
||||||
|
if (filter.demographics.isNotEmpty()) criterion(QueryCriteria.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(QueryCriteria.Match(PUBLICATION_YEAR, filter.year))
|
||||||
|
}
|
||||||
|
filter.locale?.takeIf { it != null }?.let {
|
||||||
|
criterion(QueryCriteria.Include(LANGUAGE, setOf(it)))
|
||||||
|
}
|
||||||
|
filter.originalLocale?.takeIf { it != null }?.let {
|
||||||
|
criterion(QueryCriteria.Include(ORIGINAL_LANGUAGE, setOf(it)))
|
||||||
|
}
|
||||||
|
filter.query?.takeIf { it.isNotBlank() }?.let {
|
||||||
|
criterion(QueryCriteria.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 QueryCriteria.Include<*> -> handleInclude(this, criterion)
|
||||||
|
is QueryCriteria.Exclude<*> -> handleExclude(this, criterion)
|
||||||
|
is QueryCriteria.Range<*> -> handleBetween(this, criterion)
|
||||||
|
is QueryCriteria.Match<*> -> handleMatch(this, criterion)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}.build()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun handleInclude(builder: MangaListFilter.Builder, criterion: QueryCriteria.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 UnsupportedOperationException("Unsupported field for Include criterion: ${criterion.field}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun handleExclude(builder: MangaListFilter.Builder, criterion: QueryCriteria.Exclude<*>) {
|
||||||
|
val type = criterion.field.type
|
||||||
|
|
||||||
|
when (criterion.field) {
|
||||||
|
TAG -> builder.excludeTags(filterValues(criterion, type))
|
||||||
|
else -> throw UnsupportedOperationException("Unsupported field for Exclude criterion: ${criterion.field}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun handleBetween(builder: MangaListFilter.Builder, criterion: QueryCriteria.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 UnsupportedOperationException("Unsupported field for Between criterion: ${criterion.field}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun handleMatch(builder: MangaListFilter.Builder, criterion: QueryCriteria.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 UnsupportedOperationException("Unsupported field for Match criterion: ${criterion.field}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Suppress("UNCHECKED_CAST")
|
||||||
|
private fun <T> filterValues(criterion: QueryCriteria.Include<*>, type: Class<*>): List<T> {
|
||||||
|
return criterion.values.filter { type.isInstance(it) } as List<T>
|
||||||
|
}
|
||||||
|
|
||||||
|
@Suppress("UNCHECKED_CAST")
|
||||||
|
private fun <T> filterValues(criterion: QueryCriteria.Exclude<*>, type: Class<*>): List<T> {
|
||||||
|
return criterion.values.filter { type.isInstance(it) } as List<T>
|
||||||
|
}
|
||||||
|
|
||||||
|
@Suppress("UNCHECKED_CAST")
|
||||||
|
private fun <T> getFirstValue(criterion: QueryCriteria.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 != null && 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.Locale
|
||||||
|
|
||||||
|
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,90 @@
|
|||||||
|
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.*
|
||||||
|
import org.koitharu.kotatsu.parsers.model.ContentType.*
|
||||||
|
import org.koitharu.kotatsu.parsers.model.Demographic.SEINEN
|
||||||
|
import org.koitharu.kotatsu.parsers.model.search.*
|
||||||
|
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<UnsupportedOperationException> {
|
||||||
|
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