Repo created
This commit is contained in:
parent
75dc487a7a
commit
39c29d175b
6317 changed files with 388324 additions and 2 deletions
18
feature/search/impl-legacy/build.gradle.kts
Normal file
18
feature/search/impl-legacy/build.gradle.kts
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
plugins {
|
||||
id(ThunderbirdPlugins.Library.kmp)
|
||||
alias(libs.plugins.kotlin.serialization)
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "net.thunderbird.feature.search.legacy"
|
||||
}
|
||||
|
||||
kotlin {
|
||||
sourceSets {
|
||||
commonMain.dependencies {
|
||||
implementation(projects.feature.mail.account.api)
|
||||
|
||||
implementation(libs.kotlinx.serialization.json)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,207 @@
|
|||
package net.thunderbird.feature.search.legacy
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
import net.thunderbird.feature.search.legacy.api.MessageSearchField
|
||||
import net.thunderbird.feature.search.legacy.api.MessageSearchSpecification
|
||||
import net.thunderbird.feature.search.legacy.api.SearchAttribute
|
||||
import net.thunderbird.feature.search.legacy.api.SearchCondition
|
||||
|
||||
/**
|
||||
* This class represents a local search.
|
||||
*
|
||||
* Removing conditions could be done through matching there unique id in the leafset and then
|
||||
* removing them from the tree.
|
||||
*
|
||||
* TODO implement a complete addAllowedFolder method
|
||||
* TODO conflicting conditions check on add
|
||||
* TODO duplicate condition checking?
|
||||
* TODO assign each node a unique id that's used to retrieve it from the leafset and remove.
|
||||
*/
|
||||
@Serializable
|
||||
@Suppress("TooManyFunctions")
|
||||
class LocalMessageSearch : MessageSearchSpecification {
|
||||
var id: String = ""
|
||||
var isManualSearch: Boolean = false
|
||||
|
||||
// since the uuid isn't in the message table it's not in the tree neither
|
||||
private val accountUuidSet: MutableSet<String> = HashSet()
|
||||
private var conditionsRoot: SearchConditionTreeNode? = null
|
||||
|
||||
/**
|
||||
* Gets the leafset of the related condition tree.
|
||||
*
|
||||
* @return All the leaf conditions as a set.
|
||||
*/
|
||||
var leafSet: MutableSet<SearchConditionTreeNode> = HashSet()
|
||||
private set
|
||||
|
||||
/**
|
||||
* Add a new account to the search. When no accounts are
|
||||
* added manually we search all accounts on the device.
|
||||
*
|
||||
* @param uuid Uuid of the account to be added.
|
||||
*/
|
||||
fun addAccountUuid(uuid: String) {
|
||||
accountUuidSet.add(uuid)
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds the provided node as the second argument of an AND
|
||||
* clause to this node.
|
||||
*
|
||||
* @param field Message table field to match against.
|
||||
* @param value Value to look for.
|
||||
* @param attribute Attribute to use when matching.
|
||||
*/
|
||||
fun and(field: MessageSearchField, value: String, attribute: SearchAttribute) {
|
||||
and(SearchCondition(field, attribute, value))
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds the provided condition as the second argument of an AND
|
||||
* clause to this node.
|
||||
*
|
||||
* @param condition Condition to 'AND' with.
|
||||
* @return New top AND node, new root.
|
||||
*/
|
||||
fun and(condition: SearchCondition): SearchConditionTreeNode {
|
||||
val node = SearchConditionTreeNode.Builder(condition).build()
|
||||
return and(node)
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds the provided node as the second argument of an AND
|
||||
* clause to this node.
|
||||
*
|
||||
* @param node Node to 'AND' with.
|
||||
* @return New top AND node, new root.
|
||||
*/
|
||||
fun and(node: SearchConditionTreeNode): SearchConditionTreeNode {
|
||||
leafSet.addAll(node.getLeafSet())
|
||||
|
||||
conditionsRoot = conditionsRoot?.let {
|
||||
SearchConditionTreeNode.Builder(it)
|
||||
.and(node)
|
||||
.build()
|
||||
} ?: node
|
||||
|
||||
return conditionsRoot!!
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds the provided condition as the second argument of an OR
|
||||
* clause to this node.
|
||||
*
|
||||
* @param condition Condition to 'OR' with.
|
||||
* @return New top OR node, new root.
|
||||
*/
|
||||
fun or(condition: SearchCondition): SearchConditionTreeNode {
|
||||
val node = SearchConditionTreeNode.Builder(condition).build()
|
||||
return or(node)
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds the provided node as the second argument of an OR
|
||||
* clause to this node.
|
||||
*
|
||||
* @param node Node to 'OR' with.
|
||||
* @return New top OR node, new root.
|
||||
*/
|
||||
fun or(node: SearchConditionTreeNode): SearchConditionTreeNode {
|
||||
leafSet.addAll(node.getLeafSet())
|
||||
|
||||
conditionsRoot = conditionsRoot?.let {
|
||||
SearchConditionTreeNode.Builder(it)
|
||||
.or(node)
|
||||
.build()
|
||||
} ?: node
|
||||
|
||||
return conditionsRoot!!
|
||||
}
|
||||
|
||||
/**
|
||||
* TODO
|
||||
* FOR NOW: And the folder with the root.
|
||||
*
|
||||
* Add the folder as another folder to search in. The folder
|
||||
* will be added AND to the root if no 'folder subtree' was found.
|
||||
* Otherwise the folder will be added OR to that tree.
|
||||
*/
|
||||
fun addAllowedFolder(folderId: Long) {
|
||||
/*
|
||||
* TODO find folder sub-tree
|
||||
* - do and on root of it & rest of search
|
||||
* - do or between folder nodes
|
||||
*/
|
||||
conditionsRoot = and(SearchCondition(MessageSearchField.FOLDER, SearchAttribute.EQUALS, folderId.toString()))
|
||||
}
|
||||
|
||||
/**
|
||||
* TODO make this more advanced!
|
||||
* This is a temporary solution that does NOT WORK for
|
||||
* real searches because of possible extra conditions to a folder requirement.
|
||||
*/
|
||||
val folderIds: List<Long>
|
||||
get() = leafSet
|
||||
.mapNotNull { node ->
|
||||
node.condition?.takeIf {
|
||||
it.field == MessageSearchField.FOLDER && it.attribute == SearchAttribute.EQUALS
|
||||
}?.value?.toLong()
|
||||
}
|
||||
|
||||
/**
|
||||
* Safely gets a folder ID at the specified index, returning null if the index is out of bounds.
|
||||
* This helps prevent IndexOutOfBoundsException when accessing folder IDs.
|
||||
*
|
||||
* @param index The index of the folder ID to get
|
||||
* @return The folder ID at the specified index, or null if the index is out of bounds
|
||||
*/
|
||||
fun getFolderIdAtIndexOrNull(index: Int): Long? {
|
||||
return folderIds.getOrNull(index)
|
||||
}
|
||||
|
||||
/**
|
||||
* TODO THIS HAS TO GO!!!!
|
||||
* very dirty fix for remotesearch support atm
|
||||
*/
|
||||
val remoteSearchArguments: String?
|
||||
get() = leafSet.firstNotNullOfOrNull { node ->
|
||||
node.condition?.takeIf {
|
||||
it.field == MessageSearchField.SUBJECT || it.field == MessageSearchField.SENDER
|
||||
}?.value
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns all the account uuids that this search will try to match against. Might be an empty array, in which
|
||||
* case all accounts should be included in the search.
|
||||
*/
|
||||
override val accountUuids: Set<String>
|
||||
get() = accountUuidSet.toSet()
|
||||
|
||||
/**
|
||||
* Returns whether or not to search all accounts.
|
||||
*
|
||||
* @return `true` if all accounts should be searched.
|
||||
*/
|
||||
fun searchAllAccounts(): Boolean = accountUuidSet.isEmpty()
|
||||
|
||||
/**
|
||||
* Get the condition tree.
|
||||
*
|
||||
* @return The root node of the related conditions tree.
|
||||
*/
|
||||
override val conditions: SearchConditionTreeNode
|
||||
get() = conditionsRoot ?: SearchConditionTreeNode.Builder(
|
||||
SearchCondition(MessageSearchField.SUBJECT, SearchAttribute.CONTAINS, ""),
|
||||
).build()
|
||||
|
||||
override fun toString(): String = buildString {
|
||||
append("LocalSearch(")
|
||||
append("id='").append(id).append("', ")
|
||||
append("isManualSearch=").append(isManualSearch).append(", ")
|
||||
append("accountUuidSet=").append(accountUuidSet).append(", ")
|
||||
append("conditionsRoot=").append(conditionsRoot).append(", ")
|
||||
append("leafSet=").append(leafSet)
|
||||
append(")")
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,52 @@
|
|||
package net.thunderbird.feature.search.legacy
|
||||
|
||||
import net.thunderbird.feature.mail.account.api.BaseAccount
|
||||
import net.thunderbird.feature.search.legacy.api.MessageSearchField
|
||||
import net.thunderbird.feature.search.legacy.api.SearchAttribute
|
||||
|
||||
/**
|
||||
* This class is basically a wrapper around a LocalSearch. It allows to expose it as an account.
|
||||
* This is a meta-account containing all the messages that match the search.
|
||||
*/
|
||||
class SearchAccount(
|
||||
val id: String,
|
||||
search: LocalMessageSearch,
|
||||
override val name: String,
|
||||
override val email: String,
|
||||
) : BaseAccount {
|
||||
/**
|
||||
* Returns the ID of this `SearchAccount` instance.
|
||||
*
|
||||
* This isn't really a UUID. But since we don't expose this value to other apps and we only use the account UUID
|
||||
* as opaque string (e.g. as key in a `Map`) we're fine.
|
||||
*
|
||||
* Using a constant string is necessary to identify the same search account even when the corresponding
|
||||
* [SearchAccount] object has been recreated.
|
||||
*/
|
||||
override val uuid: String = id
|
||||
|
||||
val relatedSearch: LocalMessageSearch = search
|
||||
|
||||
companion object {
|
||||
const val UNIFIED_INBOX = "unified_inbox"
|
||||
const val NEW_MESSAGES = "new_messages"
|
||||
|
||||
@JvmStatic
|
||||
fun createUnifiedInboxAccount(
|
||||
unifiedInboxTitle: String,
|
||||
unifiedInboxDetail: String,
|
||||
): SearchAccount {
|
||||
val tmpSearch = LocalMessageSearch().apply {
|
||||
id = UNIFIED_INBOX
|
||||
and(MessageSearchField.INTEGRATE, "1", SearchAttribute.EQUALS)
|
||||
}
|
||||
|
||||
return SearchAccount(
|
||||
id = UNIFIED_INBOX,
|
||||
search = tmpSearch,
|
||||
name = unifiedInboxTitle,
|
||||
email = unifiedInboxDetail,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,178 @@
|
|||
package net.thunderbird.feature.search.legacy
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
import net.thunderbird.feature.search.legacy.api.SearchAttribute
|
||||
import net.thunderbird.feature.search.legacy.api.SearchCondition
|
||||
import net.thunderbird.feature.search.legacy.api.SearchFieldType
|
||||
|
||||
/**
|
||||
* Represents a node in a boolean expression tree for evaluating search conditions.
|
||||
*
|
||||
* This tree is used to construct logical queries by combining simple {@link SearchCondition}
|
||||
* leaf nodes using logical operators: AND, OR, and NOT.
|
||||
*
|
||||
* The tree consists of:
|
||||
* - Leaf nodes with `operator == CONDITION`, containing a single {@link SearchCondition}
|
||||
* - Internal nodes with `operator == AND` or `OR`, referencing two child nodes
|
||||
* - Unary nodes with `operator == NOT`, referencing one child node (`left`)
|
||||
*
|
||||
* The tree supports immutable construction via the {@link Builder} class.
|
||||
*
|
||||
* Example tree:
|
||||
*
|
||||
* OR
|
||||
* / \
|
||||
* NOT CONDITION(subject contains "invoice")
|
||||
* |
|
||||
* AND
|
||||
* / \
|
||||
* A B
|
||||
*
|
||||
* Where:
|
||||
* - A = CONDITION(from CONTAINS "bob@example.com")
|
||||
* - B = CONDITION(to CONTAINS "alice@example.com")
|
||||
*
|
||||
* Represents logic:
|
||||
* NOT (from CONTAINS "bob@example.com" AND to CONTAINS "alice@example.com")
|
||||
* OR subject CONTAINS "invoice"
|
||||
*
|
||||
* Use `getLeafSet()` to extract all base conditions for analysis or UI rendering.
|
||||
*
|
||||
* Example usage (Kotlin):
|
||||
*
|
||||
* ```kotlin
|
||||
* val tree = SearchConditionTreeNode.Builder(conditionA)
|
||||
* .and(conditionB)
|
||||
* .not()
|
||||
* .or(conditionC)
|
||||
* .build()
|
||||
* ```
|
||||
*
|
||||
* This would produce: ((NOT (A AND B)) OR C)
|
||||
*
|
||||
* @see SearchCondition
|
||||
* @see LocalMessageSearch
|
||||
*/
|
||||
@Serializable
|
||||
class SearchConditionTreeNode private constructor(
|
||||
val operator: Operator,
|
||||
val condition: SearchCondition? = null,
|
||||
var left: SearchConditionTreeNode? = null,
|
||||
var right: SearchConditionTreeNode? = null,
|
||||
) {
|
||||
|
||||
@Serializable
|
||||
enum class Operator {
|
||||
AND,
|
||||
NOT,
|
||||
OR,
|
||||
CONDITION,
|
||||
}
|
||||
|
||||
fun getLeafSet(): Set<SearchConditionTreeNode> {
|
||||
val leafSet = mutableSetOf<SearchConditionTreeNode>()
|
||||
collectLeaves(this, leafSet)
|
||||
return leafSet
|
||||
}
|
||||
|
||||
private fun collectLeaves(node: SearchConditionTreeNode?, leafSet: MutableSet<SearchConditionTreeNode>) {
|
||||
if (node == null) return
|
||||
|
||||
when (node.operator) {
|
||||
Operator.CONDITION -> leafSet.add(node)
|
||||
|
||||
Operator.NOT -> {
|
||||
// Unary: only traverse left
|
||||
collectLeaves(node.left, leafSet)
|
||||
}
|
||||
|
||||
Operator.AND, Operator.OR -> {
|
||||
collectLeaves(node.left, leafSet)
|
||||
collectLeaves(node.right, leafSet)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun toString(): String {
|
||||
return when (operator) {
|
||||
Operator.AND, Operator.OR -> {
|
||||
val leftStr = left?.toString() ?: "null"
|
||||
val rightStr = right?.toString() ?: "null"
|
||||
"($leftStr ${operator.name} $rightStr)"
|
||||
}
|
||||
|
||||
Operator.CONDITION -> condition.toString()
|
||||
Operator.NOT -> "(NOT ${left?.toString() ?: "null"})"
|
||||
}
|
||||
}
|
||||
|
||||
class Builder(
|
||||
private var root: SearchConditionTreeNode,
|
||||
) {
|
||||
constructor(condition: SearchCondition) : this(SearchConditionTreeNode(Operator.CONDITION, condition))
|
||||
|
||||
private fun validateCondition(condition: SearchCondition) {
|
||||
if (condition.field.fieldType == SearchFieldType.CUSTOM &&
|
||||
condition.attribute != SearchAttribute.CONTAINS
|
||||
) {
|
||||
error("Custom fields can only be used with the CONTAINS attribute")
|
||||
}
|
||||
}
|
||||
|
||||
private fun validateTree(node: SearchConditionTreeNode?) {
|
||||
if (node == null) return
|
||||
|
||||
if (node.operator == Operator.CONDITION) {
|
||||
if (node.condition == null) {
|
||||
error("CONDITION nodes must have a condition")
|
||||
}
|
||||
validateCondition(node.condition)
|
||||
} else {
|
||||
validateTree(node.left)
|
||||
validateTree(node.right)
|
||||
}
|
||||
}
|
||||
|
||||
fun and(condition: SearchCondition): Builder {
|
||||
validateCondition(condition)
|
||||
return and(SearchConditionTreeNode(Operator.CONDITION, condition))
|
||||
}
|
||||
|
||||
fun and(node: SearchConditionTreeNode): Builder {
|
||||
validateTree(node)
|
||||
root = SearchConditionTreeNode(
|
||||
operator = Operator.AND,
|
||||
left = root,
|
||||
right = node,
|
||||
)
|
||||
return this
|
||||
}
|
||||
|
||||
fun not(): Builder {
|
||||
root = SearchConditionTreeNode(
|
||||
operator = Operator.NOT,
|
||||
left = root,
|
||||
)
|
||||
return this
|
||||
}
|
||||
|
||||
fun or(condition: SearchCondition): Builder {
|
||||
validateCondition(condition)
|
||||
return or(SearchConditionTreeNode(Operator.CONDITION, condition))
|
||||
}
|
||||
|
||||
fun or(node: SearchConditionTreeNode): Builder {
|
||||
validateTree(node)
|
||||
root = SearchConditionTreeNode(
|
||||
operator = Operator.OR,
|
||||
left = root,
|
||||
right = node,
|
||||
)
|
||||
return this
|
||||
}
|
||||
|
||||
fun build(): SearchConditionTreeNode {
|
||||
return root
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,43 @@
|
|||
package net.thunderbird.feature.search.legacy.api
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
/**
|
||||
* Represents a field that can be searched in messages.
|
||||
* Each enum value corresponds to a specific message attribute that can be used in search queries.
|
||||
*
|
||||
* @property fieldName The name of the database column associated with this search field.
|
||||
* @property fieldType The type of the search field, which determines how it can be queried.
|
||||
* @property customQueryTemplate An optional custom query template for fields that require special handling.
|
||||
*/
|
||||
@Serializable
|
||||
enum class MessageSearchField(
|
||||
override val fieldName: String,
|
||||
override val fieldType: SearchFieldType,
|
||||
override val customQueryTemplate: String? = null,
|
||||
) : SearchField {
|
||||
CC("cc_list", SearchFieldType.TEXT),
|
||||
DATE("date", SearchFieldType.NUMBER),
|
||||
FLAG("flags", SearchFieldType.TEXT),
|
||||
ID("id", SearchFieldType.NUMBER),
|
||||
SENDER("sender_list", SearchFieldType.TEXT),
|
||||
SUBJECT("subject", SearchFieldType.TEXT),
|
||||
UID("uid", SearchFieldType.TEXT),
|
||||
TO("to_list", SearchFieldType.TEXT),
|
||||
FOLDER("folder_id", SearchFieldType.NUMBER),
|
||||
BCC("bcc_list", SearchFieldType.TEXT),
|
||||
REPLY_TO("reply_to_list", SearchFieldType.TEXT),
|
||||
MESSAGE_CONTENTS(
|
||||
fieldName = "message_contents",
|
||||
fieldType = SearchFieldType.CUSTOM,
|
||||
customQueryTemplate = "messages.id IN (SELECT docid FROM messages_fulltext WHERE fulltext MATCH ?)",
|
||||
),
|
||||
ATTACHMENT_COUNT("attachment_count", SearchFieldType.NUMBER),
|
||||
DELETED("deleted", SearchFieldType.NUMBER),
|
||||
THREAD_ID("threads.root", SearchFieldType.NUMBER),
|
||||
INTEGRATE("integrate", SearchFieldType.NUMBER),
|
||||
NEW_MESSAGE("new_message", SearchFieldType.NUMBER),
|
||||
READ("read", SearchFieldType.NUMBER),
|
||||
FLAGGED("flagged", SearchFieldType.NUMBER),
|
||||
VISIBLE("visible", SearchFieldType.NUMBER),
|
||||
}
|
||||
|
|
@ -0,0 +1,27 @@
|
|||
package net.thunderbird.feature.search.legacy.api
|
||||
|
||||
import net.thunderbird.feature.search.legacy.SearchConditionTreeNode
|
||||
|
||||
/**
|
||||
* Represents a search specification that defines the accounts and conditions
|
||||
* for searching messages.
|
||||
*
|
||||
* This interface is used to encapsulate the details of a search operation,
|
||||
* including which accounts to search and the conditions that must be met
|
||||
* for messages to be included in the search results.
|
||||
*/
|
||||
interface MessageSearchSpecification {
|
||||
/**
|
||||
* Get all the uuids of accounts this search acts on.
|
||||
* @return Set of uuids.
|
||||
*/
|
||||
val accountUuids: Set<String>
|
||||
|
||||
/**
|
||||
* Returns the root node of the condition tree accompanying
|
||||
* the search.
|
||||
*
|
||||
* @return Root node of conditions tree.
|
||||
*/
|
||||
val conditions: SearchConditionTreeNode
|
||||
}
|
||||
|
|
@ -0,0 +1,37 @@
|
|||
package net.thunderbird.feature.search.legacy.api
|
||||
|
||||
import kotlinx.serialization.KSerializer
|
||||
import kotlinx.serialization.descriptors.SerialDescriptor
|
||||
import kotlinx.serialization.descriptors.buildClassSerialDescriptor
|
||||
import kotlinx.serialization.encoding.decodeStructure
|
||||
import kotlinx.serialization.encoding.encodeStructure
|
||||
import kotlinx.serialization.modules.SerializersModule
|
||||
import kotlinx.serialization.modules.polymorphic
|
||||
|
||||
val searchFieldSerializerModule = SerializersModule {
|
||||
polymorphic(SearchField::class) {
|
||||
// Register MessageSearchField as a subclass
|
||||
subclass(MessageSearchField::class, PolymorphicSearchFieldSerializer(MessageSearchField.serializer()))
|
||||
}
|
||||
}
|
||||
|
||||
class PolymorphicSearchFieldSerializer<T : SearchField>(
|
||||
private val searchFieldSerializer: KSerializer<T>,
|
||||
) : KSerializer<T> {
|
||||
override val descriptor: SerialDescriptor = buildClassSerialDescriptor(
|
||||
serialName = searchFieldSerializer.descriptor.serialName,
|
||||
) {
|
||||
element("value", searchFieldSerializer.descriptor)
|
||||
}
|
||||
|
||||
override fun deserialize(decoder: kotlinx.serialization.encoding.Decoder): T =
|
||||
decoder.decodeStructure(descriptor) {
|
||||
decodeElementIndex(descriptor)
|
||||
decodeSerializableElement(descriptor, 0, searchFieldSerializer)
|
||||
}
|
||||
|
||||
override fun serialize(encoder: kotlinx.serialization.encoding.Encoder, value: T) =
|
||||
encoder.encodeStructure(descriptor) {
|
||||
encodeSerializableElement(descriptor, 0, searchFieldSerializer, value)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,24 @@
|
|||
package net.thunderbird.feature.search.legacy.api
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
/**
|
||||
* Represents the attributes that can be used to match search conditions.
|
||||
*/
|
||||
@Serializable
|
||||
enum class SearchAttribute {
|
||||
/**
|
||||
* The value must be contained within the field.
|
||||
*/
|
||||
CONTAINS,
|
||||
|
||||
/**
|
||||
* The value must be equal with the field.
|
||||
*/
|
||||
EQUALS,
|
||||
|
||||
/**
|
||||
* The value must not be equal with the field.
|
||||
*/
|
||||
NOT_EQUALS,
|
||||
}
|
||||
|
|
@ -0,0 +1,25 @@
|
|||
package net.thunderbird.feature.search.legacy.api
|
||||
|
||||
import kotlinx.serialization.Contextual
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
/**
|
||||
* Represents a search condition describing what to search for in a specific field
|
||||
* and how to match it using an attribute.
|
||||
*
|
||||
* @param field The field to search in (e.g., subject, sender, date)
|
||||
* @param attribute The attribute to apply to the field (e.g., contains, equals, starts with)
|
||||
* @param value The value to match against the field and attribute
|
||||
*/
|
||||
@Serializable
|
||||
data class SearchCondition(
|
||||
@JvmField
|
||||
@Contextual
|
||||
val field: SearchField,
|
||||
|
||||
@JvmField
|
||||
val attribute: SearchAttribute,
|
||||
|
||||
@JvmField
|
||||
val value: String,
|
||||
)
|
||||
|
|
@ -0,0 +1,24 @@
|
|||
package net.thunderbird.feature.search.legacy.api
|
||||
|
||||
/**
|
||||
* Represents a field that can be searched.
|
||||
*/
|
||||
interface SearchField {
|
||||
/**
|
||||
* The name of the field.
|
||||
*/
|
||||
val fieldName: String
|
||||
|
||||
/**
|
||||
* The type of the field.
|
||||
*/
|
||||
val fieldType: SearchFieldType
|
||||
|
||||
/**
|
||||
* An optional custom query template for this field.
|
||||
* This can be used to define how the field should be queried in a custom way.
|
||||
*
|
||||
* Only applicable for fields with [SearchFieldType.CUSTOM].
|
||||
*/
|
||||
val customQueryTemplate: String?
|
||||
}
|
||||
|
|
@ -0,0 +1,27 @@
|
|||
package net.thunderbird.feature.search.legacy.api
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
/**
|
||||
* Represents the type of a search field.
|
||||
*
|
||||
* This enum defines the different types of fields that can be used in a search operation.
|
||||
* Each type corresponds to a specific kind of data that the field can hold.
|
||||
*/
|
||||
@Serializable
|
||||
enum class SearchFieldType {
|
||||
/**
|
||||
* Represents a field that contains text.
|
||||
*/
|
||||
TEXT,
|
||||
|
||||
/**
|
||||
* Represents a field that contains numeric values.
|
||||
*/
|
||||
NUMBER,
|
||||
|
||||
/**
|
||||
* Represents a field that contains custom search capabilities.
|
||||
*/
|
||||
CUSTOM,
|
||||
}
|
||||
|
|
@ -0,0 +1,45 @@
|
|||
package net.thunderbird.feature.search.legacy.serialization
|
||||
|
||||
import kotlin.text.Charsets
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.modules.SerializersModule
|
||||
import net.thunderbird.feature.search.legacy.LocalMessageSearch
|
||||
import net.thunderbird.feature.search.legacy.api.SearchField
|
||||
|
||||
/**
|
||||
* Provides a JSON configuration for serializing and deserializing [LocalMessageSearch] objects.
|
||||
*
|
||||
* It converts [LocalMessageSearch] to and from a ByteArray using the [Json] library.
|
||||
*/
|
||||
object LocalMessageSearchSerializer {
|
||||
private val json = Json {
|
||||
serializersModule = SerializersModule {
|
||||
// The LocalMessageSearch internally uses a SearchCondition that requires a SearchField which is an
|
||||
// interface. To allow serialization, the SearchCondition needs to mark it's SearchField property as
|
||||
// contextual and we need to provide a serializer for it.
|
||||
contextual(SearchField::class, MessageSearchFieldAsSearchFieldSerializer)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Serializes a [LocalMessageSearch] object to a ByteArray.
|
||||
*
|
||||
* @param search The [LocalMessageSearch] object to serialize.
|
||||
* @return The serialized [LocalMessageSearch] as a ByteArray.
|
||||
*/
|
||||
fun serialize(search: LocalMessageSearch): ByteArray {
|
||||
val searchString = json.encodeToString(LocalMessageSearch.serializer(), search)
|
||||
return searchString.toByteArray(Charsets.UTF_8)
|
||||
}
|
||||
|
||||
/**
|
||||
* Deserializes a ByteArray to a [LocalMessageSearch] object.
|
||||
*
|
||||
* @param bytes The ByteArray to deserialize.
|
||||
* @return The deserialized [LocalMessageSearch] object.
|
||||
*/
|
||||
fun deserialize(bytes: ByteArray): LocalMessageSearch {
|
||||
val searchString = String(bytes, Charsets.UTF_8)
|
||||
return json.decodeFromString(LocalMessageSearch.serializer(), searchString)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,30 @@
|
|||
package net.thunderbird.feature.search.legacy.serialization
|
||||
|
||||
import kotlinx.serialization.KSerializer
|
||||
import kotlinx.serialization.descriptors.PrimitiveKind
|
||||
import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor
|
||||
import kotlinx.serialization.descriptors.SerialDescriptor
|
||||
import kotlinx.serialization.encoding.Decoder
|
||||
import kotlinx.serialization.encoding.Encoder
|
||||
import net.thunderbird.feature.search.legacy.api.MessageSearchField
|
||||
import net.thunderbird.feature.search.legacy.api.SearchField
|
||||
|
||||
/**
|
||||
* A serializer for [SearchField] that specifically handles [MessageSearchField].
|
||||
* This serializer is used to convert [MessageSearchField] to and from its string representation.
|
||||
*/
|
||||
object MessageSearchFieldAsSearchFieldSerializer : KSerializer<SearchField> {
|
||||
override val descriptor: SerialDescriptor =
|
||||
PrimitiveSerialDescriptor("SearchField", PrimitiveKind.STRING)
|
||||
|
||||
override fun serialize(encoder: Encoder, value: SearchField) {
|
||||
val enumValue = value as? MessageSearchField
|
||||
?: throw IllegalArgumentException("Only MessageSearchField is supported")
|
||||
encoder.encodeString(enumValue.name)
|
||||
}
|
||||
|
||||
override fun deserialize(decoder: Decoder): SearchField {
|
||||
val name = decoder.decodeString()
|
||||
return MessageSearchField.valueOf(name)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,165 @@
|
|||
package net.thunderbird.feature.search.legacy.sql
|
||||
|
||||
import net.thunderbird.feature.search.legacy.SearchConditionTreeNode
|
||||
import net.thunderbird.feature.search.legacy.api.SearchAttribute
|
||||
import net.thunderbird.feature.search.legacy.api.SearchCondition
|
||||
import net.thunderbird.feature.search.legacy.api.SearchFieldType
|
||||
|
||||
/**
|
||||
* Builds a SQL query string based on a search condition tree and creates the selection arguments.
|
||||
*
|
||||
* This class constructs a SQL WHERE clause from a tree of search conditions, allowing for complex
|
||||
* logical expressions using AND, OR, and NOT operators. It supports custom fields with templates
|
||||
* for specific query formats.
|
||||
*
|
||||
* Example usage:
|
||||
* ```
|
||||
* val query = SqlWhereClause.Builder()
|
||||
* .withConditions(searchConditionTree)
|
||||
* .build()
|
||||
* ```
|
||||
*/
|
||||
class SqlWhereClause private constructor(
|
||||
val selection: String,
|
||||
val selectionArgs: List<String>,
|
||||
) {
|
||||
class Builder {
|
||||
private var root: SearchConditionTreeNode? = null
|
||||
|
||||
/**
|
||||
* Sets the root of the search condition tree.
|
||||
*
|
||||
* This method is used to specify the root node of the search condition tree that will be
|
||||
* used to build the SQL query. It will replace any previously set conditions.
|
||||
*
|
||||
* @param node The root node of the search condition tree.
|
||||
*/
|
||||
fun withConditions(node: SearchConditionTreeNode): Builder {
|
||||
root = node
|
||||
return this
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the SQL query string based on the provided conditions and creates the selection arguments.
|
||||
*
|
||||
* @return The constructed SQL query string.
|
||||
*/
|
||||
fun build(): SqlWhereClause {
|
||||
val arguments = mutableListOf<String>()
|
||||
val query = StringBuilder()
|
||||
buildWhereClause(root, query, arguments)
|
||||
|
||||
return SqlWhereClause(
|
||||
selection = query.toString(),
|
||||
selectionArgs = arguments,
|
||||
)
|
||||
}
|
||||
|
||||
private fun buildWhereClause(
|
||||
node: SearchConditionTreeNode?,
|
||||
query: StringBuilder,
|
||||
selectionArgs: MutableList<String>,
|
||||
) {
|
||||
if (node == null) {
|
||||
query.append("1")
|
||||
return
|
||||
}
|
||||
|
||||
if (node.left == null && node.right == null) {
|
||||
val condition = node.condition ?: error("Leaf node missing condition")
|
||||
|
||||
if (condition.field.fieldType == SearchFieldType.CUSTOM) {
|
||||
require(condition.attribute == SearchAttribute.CONTAINS) {
|
||||
"Custom fields only support CONTAINS"
|
||||
}
|
||||
require(
|
||||
!(
|
||||
condition.field.customQueryTemplate == null ||
|
||||
condition.field.customQueryTemplate!!.isEmpty()
|
||||
),
|
||||
) {
|
||||
"Custom field has no query template!"
|
||||
}
|
||||
query.append(condition.field.customQueryTemplate)
|
||||
selectionArgs.add(condition.value)
|
||||
} else {
|
||||
appendCondition(condition, query, selectionArgs)
|
||||
}
|
||||
} else if (node.operator == SearchConditionTreeNode.Operator.NOT) {
|
||||
query.append("NOT (")
|
||||
buildWhereClause(node.left, query, selectionArgs)
|
||||
query.append(")")
|
||||
} else {
|
||||
// Handle binary operators (AND, OR)
|
||||
query.append("(")
|
||||
buildWhereClause(node.left, query, selectionArgs)
|
||||
query.append(") ")
|
||||
query.append(node.operator.name)
|
||||
query.append(" (")
|
||||
buildWhereClause(node.right, query, selectionArgs)
|
||||
query.append(")")
|
||||
}
|
||||
}
|
||||
|
||||
private fun appendCondition(
|
||||
condition: SearchCondition,
|
||||
query: StringBuilder,
|
||||
selectionArgs: MutableList<String>,
|
||||
) {
|
||||
query.append(condition.field.fieldName)
|
||||
appendExpressionRight(condition, query, selectionArgs)
|
||||
}
|
||||
|
||||
private fun appendExpressionRight(
|
||||
condition: SearchCondition,
|
||||
query: StringBuilder,
|
||||
selectionArgs: MutableList<String>,
|
||||
) {
|
||||
val value = condition.value
|
||||
val field = condition.field
|
||||
|
||||
query.append(" ")
|
||||
|
||||
val selectionArg: String = when (condition.attribute) {
|
||||
SearchAttribute.CONTAINS -> {
|
||||
query.append("LIKE ?")
|
||||
"%$value%"
|
||||
}
|
||||
|
||||
SearchAttribute.NOT_EQUALS -> {
|
||||
if (field.fieldType == SearchFieldType.NUMBER) {
|
||||
query.append("!= ?")
|
||||
value
|
||||
} else {
|
||||
query.append("NOT LIKE ?")
|
||||
value
|
||||
}
|
||||
}
|
||||
|
||||
SearchAttribute.EQUALS -> {
|
||||
if (field.fieldType == SearchFieldType.NUMBER) {
|
||||
query.append("= ?")
|
||||
value
|
||||
} else {
|
||||
query.append("LIKE ?")
|
||||
value
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
selectionArgs.add(selectionArg)
|
||||
}
|
||||
}
|
||||
|
||||
companion object Companion {
|
||||
// TODO: This is a workaround for ambiguous column names in the selection. Find a better solution.
|
||||
fun addPrefixToSelection(columnNames: Array<String>, prefix: String?, selection: String): String {
|
||||
var result = selection
|
||||
for (columnName in columnNames) {
|
||||
result = result.replace(("(?<=^|[^\\.])\\b$columnName\\b").toRegex(), "$prefix$columnName")
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,216 @@
|
|||
package net.thunderbird.feature.search.legacy
|
||||
|
||||
import assertk.assertThat
|
||||
import assertk.assertions.contains
|
||||
import assertk.assertions.containsExactly
|
||||
import assertk.assertions.hasSize
|
||||
import assertk.assertions.isEmpty
|
||||
import assertk.assertions.isEqualTo
|
||||
import assertk.assertions.isNotNull
|
||||
import assertk.assertions.isTrue
|
||||
import net.thunderbird.feature.search.legacy.api.MessageSearchField
|
||||
import net.thunderbird.feature.search.legacy.api.SearchAttribute
|
||||
import net.thunderbird.feature.search.legacy.api.SearchCondition
|
||||
import org.junit.Test
|
||||
|
||||
class LocalMessageSearchTest {
|
||||
|
||||
@Test
|
||||
fun `should create an empty search`() {
|
||||
// Arrange & Act
|
||||
val testSubject = LocalMessageSearch()
|
||||
|
||||
// Assert
|
||||
assertThat(testSubject.id).isEqualTo("")
|
||||
assertThat(testSubject.isManualSearch).isEqualTo(false)
|
||||
assertThat(testSubject.accountUuids).isEmpty()
|
||||
assertThat(testSubject.leafSet).isEmpty()
|
||||
assertThat(testSubject.conditions).isNotNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `should add account uuid`() {
|
||||
// Arrange
|
||||
val testSubject = LocalMessageSearch()
|
||||
val uuid = "test-uuid"
|
||||
|
||||
// Act
|
||||
testSubject.addAccountUuid(uuid)
|
||||
|
||||
// Assert
|
||||
assertThat(testSubject.accountUuids.toList()).containsExactly(uuid)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `should add condition with AND operator`() {
|
||||
// Arrange
|
||||
val testSubject = LocalMessageSearch()
|
||||
val condition = SearchCondition(MessageSearchField.SUBJECT, SearchAttribute.CONTAINS, "test")
|
||||
|
||||
// Act
|
||||
val result = testSubject.and(condition)
|
||||
|
||||
// Assert
|
||||
assertThat(result).isNotNull()
|
||||
assertThat(result.operator).isEqualTo(SearchConditionTreeNode.Operator.CONDITION)
|
||||
assertThat(result.condition).isEqualTo(condition)
|
||||
assertThat(testSubject.leafSet).hasSize(1)
|
||||
assertThat(testSubject.conditions).isEqualTo(result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `should add condition with OR operator`() {
|
||||
// Arrange
|
||||
val testSubject = LocalMessageSearch()
|
||||
val condition = SearchCondition(MessageSearchField.SUBJECT, SearchAttribute.CONTAINS, "test")
|
||||
|
||||
// Act
|
||||
val result = testSubject.or(condition)
|
||||
|
||||
// Assert
|
||||
assertThat(result).isNotNull()
|
||||
assertThat(result.operator).isEqualTo(SearchConditionTreeNode.Operator.CONDITION)
|
||||
assertThat(result.condition).isEqualTo(condition)
|
||||
assertThat(testSubject.leafSet).hasSize(1)
|
||||
assertThat(testSubject.conditions).isEqualTo(result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `should add multiple conditions with AND operator`() {
|
||||
// Arrange
|
||||
val testSubject = LocalMessageSearch()
|
||||
val condition1 = SearchCondition(MessageSearchField.SUBJECT, SearchAttribute.CONTAINS, "test1")
|
||||
val condition2 = SearchCondition(MessageSearchField.SENDER, SearchAttribute.CONTAINS, "test2")
|
||||
|
||||
// Act
|
||||
testSubject.and(condition1)
|
||||
val result = testSubject.and(condition2)
|
||||
|
||||
// Assert
|
||||
assertThat(result).isNotNull()
|
||||
assertThat(result.operator).isEqualTo(SearchConditionTreeNode.Operator.AND)
|
||||
assertThat(testSubject.leafSet).hasSize(2)
|
||||
|
||||
// Verify both conditions are in the leaf set
|
||||
val conditions = testSubject.leafSet.mapNotNull { it.condition }
|
||||
assertThat(conditions).contains(condition1)
|
||||
assertThat(conditions).contains(condition2)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `should add multiple conditions with OR operator`() {
|
||||
// Arrange
|
||||
val testSubject = LocalMessageSearch()
|
||||
val condition1 = SearchCondition(MessageSearchField.SUBJECT, SearchAttribute.CONTAINS, "test1")
|
||||
val condition2 = SearchCondition(MessageSearchField.SENDER, SearchAttribute.CONTAINS, "test2")
|
||||
|
||||
// Act
|
||||
testSubject.or(condition1)
|
||||
val result = testSubject.or(condition2)
|
||||
|
||||
// Assert
|
||||
assertThat(result).isNotNull()
|
||||
assertThat(result.operator).isEqualTo(SearchConditionTreeNode.Operator.OR)
|
||||
assertThat(testSubject.leafSet).hasSize(2)
|
||||
|
||||
// Verify both conditions are in the leaf set
|
||||
val conditions = testSubject.leafSet.mapNotNull { it.condition }
|
||||
assertThat(conditions).contains(condition1)
|
||||
assertThat(conditions).contains(condition2)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `should add allowed folder`() {
|
||||
// Arrange
|
||||
val testSubject = LocalMessageSearch()
|
||||
val folderId = 123L
|
||||
|
||||
// Act
|
||||
testSubject.addAllowedFolder(folderId)
|
||||
|
||||
// Assert
|
||||
assertThat(testSubject.folderIds.toList()).containsExactly(folderId)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `should return empty list when no folder conditions exist`() {
|
||||
// Arrange
|
||||
val testSubject = LocalMessageSearch()
|
||||
|
||||
// Act
|
||||
val result = testSubject.folderIds
|
||||
|
||||
// Assert
|
||||
assertThat(result).isEmpty()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `should return remote search arguments when subject condition exists`() {
|
||||
// Arrange
|
||||
val testSubject = LocalMessageSearch()
|
||||
val searchValue = "test query"
|
||||
testSubject.and(MessageSearchField.SUBJECT, searchValue, SearchAttribute.CONTAINS)
|
||||
|
||||
// Act
|
||||
val result = testSubject.remoteSearchArguments
|
||||
|
||||
// Assert
|
||||
assertThat(result).isEqualTo(searchValue)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `should return remote search arguments when sender condition exists`() {
|
||||
// Arrange
|
||||
val testSubject = LocalMessageSearch()
|
||||
val searchValue = "test@example.com"
|
||||
testSubject.and(MessageSearchField.SENDER, searchValue, SearchAttribute.CONTAINS)
|
||||
|
||||
// Act
|
||||
val result = testSubject.remoteSearchArguments
|
||||
|
||||
// Assert
|
||||
assertThat(result).isEqualTo(searchValue)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `should return null for remote search arguments when no relevant conditions exist`() {
|
||||
// Arrange
|
||||
val testSubject = LocalMessageSearch()
|
||||
testSubject.and(MessageSearchField.FLAGGED, "1", SearchAttribute.EQUALS)
|
||||
|
||||
// Act
|
||||
val result = testSubject.remoteSearchArguments
|
||||
|
||||
// Assert
|
||||
assertThat(result).isEqualTo(null)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `should return true for searchAllAccounts when no accounts are added`() {
|
||||
// Arrange
|
||||
val testSubject = LocalMessageSearch()
|
||||
|
||||
// Act
|
||||
val result = testSubject.searchAllAccounts()
|
||||
|
||||
// Assert
|
||||
assertThat(result).isTrue()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `should return default condition when conditions is null`() {
|
||||
// Arrange
|
||||
val testSubject = LocalMessageSearch()
|
||||
|
||||
// Act
|
||||
val result = testSubject.conditions
|
||||
|
||||
// Assert
|
||||
assertThat(result).isNotNull()
|
||||
assertThat(result.operator).isEqualTo(SearchConditionTreeNode.Operator.CONDITION)
|
||||
assertThat(result.condition).isNotNull()
|
||||
assertThat(result.condition?.field).isEqualTo(MessageSearchField.SUBJECT)
|
||||
assertThat(result.condition?.attribute).isEqualTo(SearchAttribute.CONTAINS)
|
||||
assertThat(result.condition?.value).isEqualTo("")
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,235 @@
|
|||
package net.thunderbird.feature.search.legacy
|
||||
|
||||
import assertk.assertFailure
|
||||
import assertk.assertThat
|
||||
import assertk.assertions.contains
|
||||
import assertk.assertions.isEqualTo
|
||||
import assertk.assertions.isInstanceOf
|
||||
import assertk.assertions.isNotNull
|
||||
import net.thunderbird.feature.search.legacy.api.MessageSearchField
|
||||
import net.thunderbird.feature.search.legacy.api.SearchAttribute
|
||||
import net.thunderbird.feature.search.legacy.api.SearchCondition
|
||||
import net.thunderbird.feature.search.legacy.api.SearchField
|
||||
import net.thunderbird.feature.search.legacy.api.SearchFieldType
|
||||
import org.junit.Test
|
||||
|
||||
class SearchConditionTreeNodeTest {
|
||||
|
||||
data class TestSearchField(
|
||||
override val fieldName: String,
|
||||
override val fieldType: SearchFieldType,
|
||||
override val customQueryTemplate: String? = null,
|
||||
) : SearchField
|
||||
|
||||
@Test
|
||||
fun `should create a node with a condition`() {
|
||||
// Arrange
|
||||
val condition = SearchCondition(MessageSearchField.SUBJECT, SearchAttribute.CONTAINS, "test")
|
||||
|
||||
// Act
|
||||
val node = SearchConditionTreeNode.Builder(condition).build()
|
||||
|
||||
// Assert
|
||||
assertThat(node.operator).isEqualTo(SearchConditionTreeNode.Operator.CONDITION)
|
||||
assertThat(node.condition).isEqualTo(condition)
|
||||
assertThat(node.left).isEqualTo(null)
|
||||
assertThat(node.right).isEqualTo(null)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `should create a node with AND operator`() {
|
||||
// Arrange
|
||||
val condition1 = SearchCondition(MessageSearchField.SUBJECT, SearchAttribute.CONTAINS, "test")
|
||||
val condition2 = SearchCondition(MessageSearchField.SENDER, SearchAttribute.CONTAINS, "example.com")
|
||||
|
||||
// Act
|
||||
val node = SearchConditionTreeNode.Builder(condition1)
|
||||
.and(condition2)
|
||||
.build()
|
||||
|
||||
// Assert
|
||||
assertThat(node.operator).isEqualTo(SearchConditionTreeNode.Operator.AND)
|
||||
assertThat(node.condition).isEqualTo(null)
|
||||
assertThat(node.left).isNotNull()
|
||||
assertThat(node.right).isNotNull()
|
||||
|
||||
// Left node should be the first condition
|
||||
assertThat(node.left?.operator).isEqualTo(SearchConditionTreeNode.Operator.CONDITION)
|
||||
assertThat(node.left?.condition).isEqualTo(condition1)
|
||||
|
||||
// Right node should be the second condition
|
||||
assertThat(node.right?.operator).isEqualTo(SearchConditionTreeNode.Operator.CONDITION)
|
||||
assertThat(node.right?.condition).isEqualTo(condition2)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `should create a node with OR operator`() {
|
||||
// Arrange
|
||||
val condition1 = SearchCondition(MessageSearchField.SUBJECT, SearchAttribute.CONTAINS, "test")
|
||||
val condition2 = SearchCondition(MessageSearchField.SENDER, SearchAttribute.CONTAINS, "example.com")
|
||||
|
||||
// Act
|
||||
val node = SearchConditionTreeNode.Builder(condition1)
|
||||
.or(condition2)
|
||||
.build()
|
||||
|
||||
// Assert
|
||||
assertThat(node.operator).isEqualTo(SearchConditionTreeNode.Operator.OR)
|
||||
assertThat(node.condition).isEqualTo(null)
|
||||
assertThat(node.left).isNotNull()
|
||||
assertThat(node.right).isNotNull()
|
||||
|
||||
// Left node should be the first condition
|
||||
assertThat(node.left?.operator).isEqualTo(SearchConditionTreeNode.Operator.CONDITION)
|
||||
assertThat(node.left?.condition).isEqualTo(condition1)
|
||||
|
||||
// Right node should be the second condition
|
||||
assertThat(node.right?.operator).isEqualTo(SearchConditionTreeNode.Operator.CONDITION)
|
||||
assertThat(node.right?.condition).isEqualTo(condition2)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `should create a complex tree with nested conditions`() {
|
||||
// Arrange
|
||||
val condition1 = SearchCondition(MessageSearchField.SUBJECT, SearchAttribute.CONTAINS, "test")
|
||||
val condition2 = SearchCondition(MessageSearchField.SENDER, SearchAttribute.CONTAINS, "example.com")
|
||||
val condition3 = SearchCondition(MessageSearchField.FLAGGED, SearchAttribute.EQUALS, "1")
|
||||
|
||||
// Act
|
||||
val node = SearchConditionTreeNode.Builder(condition1)
|
||||
.and(
|
||||
SearchConditionTreeNode.Builder(condition2)
|
||||
.or(condition3)
|
||||
.build(),
|
||||
)
|
||||
.build()
|
||||
|
||||
// Assert
|
||||
assertThat(node.operator).isEqualTo(SearchConditionTreeNode.Operator.AND)
|
||||
assertThat(node.condition).isEqualTo(null)
|
||||
assertThat(node.left).isNotNull()
|
||||
assertThat(node.right).isNotNull()
|
||||
|
||||
// Left node should be the first condition
|
||||
assertThat(node.left?.operator).isEqualTo(SearchConditionTreeNode.Operator.CONDITION)
|
||||
assertThat(node.left?.condition).isEqualTo(condition1)
|
||||
|
||||
// Right node should be an OR node
|
||||
assertThat(node.right?.operator).isEqualTo(SearchConditionTreeNode.Operator.OR)
|
||||
assertThat(node.right?.condition).isEqualTo(null)
|
||||
|
||||
// Right node's left child should be condition2
|
||||
assertThat(node.right?.left?.operator).isEqualTo(SearchConditionTreeNode.Operator.CONDITION)
|
||||
assertThat(node.right?.left?.condition).isEqualTo(condition2)
|
||||
|
||||
// Right node's right child should be condition3
|
||||
assertThat(node.right?.right?.operator).isEqualTo(SearchConditionTreeNode.Operator.CONDITION)
|
||||
assertThat(node.right?.right?.condition).isEqualTo(condition3)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `should collect all leaf nodes`() {
|
||||
// Arrange
|
||||
val condition1 = SearchCondition(MessageSearchField.SUBJECT, SearchAttribute.CONTAINS, "test")
|
||||
val condition2 = SearchCondition(MessageSearchField.SENDER, SearchAttribute.CONTAINS, "example.com")
|
||||
val condition3 = SearchCondition(MessageSearchField.FLAGGED, SearchAttribute.EQUALS, "1")
|
||||
|
||||
val node = SearchConditionTreeNode.Builder(condition1)
|
||||
.and(
|
||||
SearchConditionTreeNode.Builder(condition2)
|
||||
.or(condition3)
|
||||
.build(),
|
||||
)
|
||||
.build()
|
||||
|
||||
// Act
|
||||
val leafSet = node.getLeafSet()
|
||||
|
||||
// Assert
|
||||
assertThat(leafSet.size).isEqualTo(3)
|
||||
|
||||
// The leaf set should contain nodes with all three conditions
|
||||
val conditions = leafSet.mapNotNull { it.condition }
|
||||
assertThat(conditions).contains(condition1)
|
||||
assertThat(conditions).contains(condition2)
|
||||
assertThat(conditions).contains(condition3)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `should create a node with NOT operator`() {
|
||||
// Arrange
|
||||
val condition = SearchCondition(MessageSearchField.SUBJECT, SearchAttribute.CONTAINS, "test")
|
||||
|
||||
// Act
|
||||
val node = SearchConditionTreeNode.Builder(condition)
|
||||
.not()
|
||||
.build()
|
||||
|
||||
// Assert
|
||||
assertThat(node.operator).isEqualTo(SearchConditionTreeNode.Operator.NOT)
|
||||
assertThat(node.condition).isEqualTo(null)
|
||||
assertThat(node.left).isNotNull()
|
||||
assertThat(node.right).isEqualTo(null)
|
||||
|
||||
// Left node should be the condition
|
||||
assertThat(node.left?.operator).isEqualTo(SearchConditionTreeNode.Operator.CONDITION)
|
||||
assertThat(node.left?.condition).isEqualTo(condition)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `should throw exception when adding condition with custom field and non-CONTAINS attribute`() {
|
||||
// Arrange
|
||||
val condition = SearchCondition(MessageSearchField.SUBJECT, SearchAttribute.CONTAINS, "test")
|
||||
val builder = SearchConditionTreeNode.Builder(condition)
|
||||
|
||||
val customField = TestSearchField(
|
||||
fieldName = "test_custom_field",
|
||||
fieldType = SearchFieldType.CUSTOM,
|
||||
customQueryTemplate = "custom_query_template",
|
||||
)
|
||||
|
||||
// Act & Assert
|
||||
assertFailure {
|
||||
builder.and(SearchCondition(customField, SearchAttribute.EQUALS, "test value"))
|
||||
}.isInstanceOf<IllegalStateException>()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `should throw exception when adding condition with custom field and non-CONTAINS attribute using or`() {
|
||||
// Arrange
|
||||
val condition = SearchCondition(MessageSearchField.SUBJECT, SearchAttribute.CONTAINS, "test")
|
||||
val builder = SearchConditionTreeNode.Builder(condition)
|
||||
|
||||
val customField = TestSearchField(
|
||||
fieldName = "test_custom_field",
|
||||
fieldType = SearchFieldType.CUSTOM,
|
||||
customQueryTemplate = "custom_query_template",
|
||||
)
|
||||
|
||||
// Act & Assert
|
||||
assertFailure {
|
||||
builder.or(SearchCondition(customField, SearchAttribute.EQUALS, "test value"))
|
||||
}.isInstanceOf<IllegalStateException>()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `should throw exception when adding node with invalid condition`() {
|
||||
// Arrange
|
||||
val customField = TestSearchField(
|
||||
fieldName = "test_custom_field",
|
||||
fieldType = SearchFieldType.CUSTOM,
|
||||
customQueryTemplate = "custom_query_template",
|
||||
)
|
||||
val validCondition = SearchCondition(MessageSearchField.SUBJECT, SearchAttribute.CONTAINS, "valid")
|
||||
val validBuilder = SearchConditionTreeNode.Builder(validCondition)
|
||||
|
||||
// Add an invalid condition to the builder
|
||||
val invalidCondition = SearchCondition(customField, SearchAttribute.EQUALS, "invalid")
|
||||
|
||||
// Act & Assert
|
||||
// This should throw an exception when trying to add the invalid condition to the builder
|
||||
assertFailure {
|
||||
validBuilder.and(invalidCondition)
|
||||
}.isInstanceOf<IllegalStateException>()
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,143 @@
|
|||
package net.thunderbird.feature.search.legacy.serialization
|
||||
|
||||
import assertk.assertThat
|
||||
import assertk.assertions.isEqualTo
|
||||
import assertk.assertions.isNotNull
|
||||
import kotlin.text.Charsets
|
||||
import net.thunderbird.feature.search.legacy.LocalMessageSearch
|
||||
import net.thunderbird.feature.search.legacy.api.MessageSearchField
|
||||
import net.thunderbird.feature.search.legacy.api.SearchAttribute
|
||||
import org.junit.Test
|
||||
|
||||
class LocalMessageSearchSerializerTest {
|
||||
|
||||
@Test
|
||||
fun `should serialize empty search`() {
|
||||
// Arrange
|
||||
val search = LocalMessageSearch()
|
||||
|
||||
// Act
|
||||
val result = LocalMessageSearchSerializer.serialize(search)
|
||||
|
||||
// Assert
|
||||
assertThat(result).isNotNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `should deserialize empty search`() {
|
||||
// Arrange
|
||||
val search = LocalMessageSearch()
|
||||
val bytes = LocalMessageSearchSerializer.serialize(search)
|
||||
|
||||
// Act
|
||||
val result = LocalMessageSearchSerializer.deserialize(bytes)
|
||||
|
||||
// Assert
|
||||
assertThat(result).isNotNull()
|
||||
assertThat(result.id).isEqualTo("")
|
||||
assertThat(result.isManualSearch).isEqualTo(false)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `should round-trip serialize and deserialize empty search`() {
|
||||
// Arrange
|
||||
val search = LocalMessageSearch()
|
||||
|
||||
// Act
|
||||
val bytes = LocalMessageSearchSerializer.serialize(search)
|
||||
val result = LocalMessageSearchSerializer.deserialize(bytes)
|
||||
|
||||
// Assert
|
||||
assertThat(result.id).isEqualTo(search.id)
|
||||
assertThat(result.isManualSearch).isEqualTo(search.isManualSearch)
|
||||
assertThat(result.accountUuids).isEqualTo(search.accountUuids)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `should round-trip serialize and deserialize search with account uuid`() {
|
||||
// Arrange
|
||||
val search = LocalMessageSearch()
|
||||
search.addAccountUuid("test-account-uuid")
|
||||
|
||||
// Act
|
||||
val bytes = LocalMessageSearchSerializer.serialize(search)
|
||||
val result = LocalMessageSearchSerializer.deserialize(bytes)
|
||||
|
||||
// Assert
|
||||
assertThat(result.accountUuids).isEqualTo(search.accountUuids)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `should round-trip serialize and deserialize search with condition`() {
|
||||
// Arrange
|
||||
val search = LocalMessageSearch()
|
||||
search.and(MessageSearchField.SUBJECT, "test subject", SearchAttribute.CONTAINS)
|
||||
|
||||
// Act
|
||||
val bytes = LocalMessageSearchSerializer.serialize(search)
|
||||
val result = LocalMessageSearchSerializer.deserialize(bytes)
|
||||
|
||||
// Assert
|
||||
val originalCondition = search.conditions.condition
|
||||
val resultCondition = result.conditions.condition
|
||||
|
||||
assertThat(resultCondition).isNotNull()
|
||||
assertThat(originalCondition).isNotNull()
|
||||
assertThat(resultCondition!!.field).isEqualTo(originalCondition!!.field)
|
||||
assertThat(resultCondition.attribute).isEqualTo(originalCondition.attribute)
|
||||
assertThat(resultCondition.value).isEqualTo(originalCondition.value)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `should round-trip serialize and deserialize search with multiple conditions`() {
|
||||
// Arrange
|
||||
val search = LocalMessageSearch()
|
||||
search.and(MessageSearchField.SUBJECT, "test subject", SearchAttribute.CONTAINS)
|
||||
search.and(MessageSearchField.SENDER, "test sender", SearchAttribute.CONTAINS)
|
||||
|
||||
// Act
|
||||
val bytes = LocalMessageSearchSerializer.serialize(search)
|
||||
val result = LocalMessageSearchSerializer.deserialize(bytes)
|
||||
|
||||
// Assert
|
||||
// Since the conditions are in a tree structure, we'll just verify the leaf set size
|
||||
assertThat(result.leafSet.size).isEqualTo(search.leafSet.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `should handle special characters correctly`() {
|
||||
// Arrange
|
||||
val search = LocalMessageSearch()
|
||||
val specialChars = "Special characters: äöüß@€$%&*()[]{}|<>?/\\=+"
|
||||
search.and(MessageSearchField.SUBJECT, specialChars, SearchAttribute.CONTAINS)
|
||||
|
||||
// Act
|
||||
val bytes = LocalMessageSearchSerializer.serialize(search)
|
||||
val result = LocalMessageSearchSerializer.deserialize(bytes)
|
||||
|
||||
// Assert
|
||||
val originalCondition = search.conditions.condition
|
||||
val resultCondition = result.conditions.condition
|
||||
|
||||
assertThat(resultCondition).isNotNull()
|
||||
assertThat(originalCondition).isNotNull()
|
||||
assertThat(resultCondition!!.value).isEqualTo(originalCondition!!.value)
|
||||
assertThat(resultCondition.value).isEqualTo(specialChars)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `should use UTF-8 encoding for serialization`() {
|
||||
// Arrange
|
||||
val search = LocalMessageSearch()
|
||||
val utf8String = "UTF-8 characters: 你好, こんにちは, 안녕하세요"
|
||||
search.and(MessageSearchField.SUBJECT, utf8String, SearchAttribute.CONTAINS)
|
||||
|
||||
// Act
|
||||
val bytes = LocalMessageSearchSerializer.serialize(search)
|
||||
|
||||
// Assert
|
||||
// Convert bytes back to string using UTF-8 and verify it contains the original string
|
||||
val jsonString = String(bytes, Charsets.UTF_8)
|
||||
assertThat(jsonString.contains(utf8String)).isEqualTo(true)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,299 @@
|
|||
package net.thunderbird.feature.search.legacy.sql
|
||||
|
||||
import assertk.assertFailure
|
||||
import assertk.assertThat
|
||||
import assertk.assertions.hasSize
|
||||
import assertk.assertions.isEqualTo
|
||||
import assertk.assertions.isInstanceOf
|
||||
import net.thunderbird.feature.search.legacy.SearchConditionTreeNode
|
||||
import net.thunderbird.feature.search.legacy.api.MessageSearchField
|
||||
import net.thunderbird.feature.search.legacy.api.SearchAttribute
|
||||
import net.thunderbird.feature.search.legacy.api.SearchCondition
|
||||
import net.thunderbird.feature.search.legacy.api.SearchField
|
||||
import net.thunderbird.feature.search.legacy.api.SearchFieldType
|
||||
import org.junit.Test
|
||||
|
||||
class SqlWhereClauseTest {
|
||||
|
||||
data class TestSearchField(
|
||||
override val fieldName: String,
|
||||
override val fieldType: SearchFieldType,
|
||||
override val customQueryTemplate: String? = null,
|
||||
) : SearchField
|
||||
|
||||
@Test
|
||||
fun `should build correct SQL query for NOT operator`() {
|
||||
// Arrange
|
||||
val condition = SearchCondition(MessageSearchField.SUBJECT, SearchAttribute.CONTAINS, "test")
|
||||
val node = SearchConditionTreeNode.Builder(condition)
|
||||
.not()
|
||||
.build()
|
||||
|
||||
// Act
|
||||
val result = SqlWhereClause.Builder()
|
||||
.withConditions(node)
|
||||
.build()
|
||||
|
||||
// Assert
|
||||
assertThat(result.selection).isEqualTo("NOT (subject LIKE ?)")
|
||||
assertThat(result.selectionArgs).hasSize(1)
|
||||
assertThat(result.selectionArgs[0]).isEqualTo("%test%")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `should build correct SQL query for complex expression with NOT operator`() {
|
||||
// Arrange
|
||||
val condition1 = SearchCondition(MessageSearchField.SUBJECT, SearchAttribute.CONTAINS, "test")
|
||||
val condition2 = SearchCondition(MessageSearchField.SENDER, SearchAttribute.CONTAINS, "example.com")
|
||||
|
||||
val node = SearchConditionTreeNode.Builder(condition1)
|
||||
.and(condition2)
|
||||
.not()
|
||||
.build()
|
||||
|
||||
// Act
|
||||
val result = SqlWhereClause.Builder()
|
||||
.withConditions(node)
|
||||
.build()
|
||||
|
||||
// Assert
|
||||
assertThat(result.selection).isEqualTo("NOT ((subject LIKE ?) AND (sender_list LIKE ?))")
|
||||
assertThat(result.selectionArgs).hasSize(2)
|
||||
assertThat(result.selectionArgs[0]).isEqualTo("%test%")
|
||||
assertThat(result.selectionArgs[1]).isEqualTo("%example.com%")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `should build correct SQL query for NOT operator combined with AND`() {
|
||||
// Arrange
|
||||
val condition1 = SearchCondition(MessageSearchField.SUBJECT, SearchAttribute.CONTAINS, "test")
|
||||
val condition2 = SearchCondition(MessageSearchField.SENDER, SearchAttribute.CONTAINS, "example.com")
|
||||
val condition3 = SearchCondition(MessageSearchField.FLAGGED, SearchAttribute.EQUALS, "1")
|
||||
|
||||
val node = SearchConditionTreeNode.Builder(condition1)
|
||||
.not()
|
||||
.and(condition2)
|
||||
.and(condition3)
|
||||
.build()
|
||||
|
||||
// Act
|
||||
val result = SqlWhereClause.Builder()
|
||||
.withConditions(node)
|
||||
.build()
|
||||
|
||||
// Assert
|
||||
assertThat(result.selection).isEqualTo("((NOT (subject LIKE ?)) AND (sender_list LIKE ?)) AND (flagged = ?)")
|
||||
assertThat(result.selectionArgs).hasSize(3)
|
||||
assertThat(result.selectionArgs[0]).isEqualTo("%test%")
|
||||
assertThat(result.selectionArgs[1]).isEqualTo("%example.com%")
|
||||
assertThat(result.selectionArgs[2]).isEqualTo("1")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `should build correct SQL query for NOT operator combined with OR`() {
|
||||
// Arrange
|
||||
val condition1 = SearchCondition(MessageSearchField.SUBJECT, SearchAttribute.CONTAINS, "test")
|
||||
val condition2 = SearchCondition(MessageSearchField.SENDER, SearchAttribute.CONTAINS, "example.com")
|
||||
val condition3 = SearchCondition(MessageSearchField.FLAGGED, SearchAttribute.EQUALS, "1")
|
||||
|
||||
val node = SearchConditionTreeNode.Builder(condition1)
|
||||
.not()
|
||||
.or(condition2)
|
||||
.or(condition3)
|
||||
.build()
|
||||
|
||||
// Act
|
||||
val result = SqlWhereClause.Builder()
|
||||
.withConditions(node)
|
||||
.build()
|
||||
|
||||
// Assert
|
||||
assertThat(result.selection).isEqualTo("((NOT (subject LIKE ?)) OR (sender_list LIKE ?)) OR (flagged = ?)")
|
||||
assertThat(result.selectionArgs).hasSize(3)
|
||||
assertThat(result.selectionArgs[0]).isEqualTo("%test%")
|
||||
assertThat(result.selectionArgs[1]).isEqualTo("%example.com%")
|
||||
assertThat(result.selectionArgs[2]).isEqualTo("1")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `should build correct SQL query for multiple NOT operators`() {
|
||||
// Arrange
|
||||
val condition1 = SearchCondition(MessageSearchField.SUBJECT, SearchAttribute.CONTAINS, "test")
|
||||
val condition2 = SearchCondition(MessageSearchField.SENDER, SearchAttribute.CONTAINS, "example.com")
|
||||
|
||||
val node = SearchConditionTreeNode.Builder(condition1)
|
||||
.not()
|
||||
.and(
|
||||
SearchConditionTreeNode.Builder(condition2)
|
||||
.not()
|
||||
.build(),
|
||||
)
|
||||
.build()
|
||||
|
||||
// Act
|
||||
val result = SqlWhereClause.Builder()
|
||||
.withConditions(node)
|
||||
.build()
|
||||
|
||||
// Assert
|
||||
assertThat(result.selection).isEqualTo("(NOT (subject LIKE ?)) AND (NOT (sender_list LIKE ?))")
|
||||
assertThat(result.selectionArgs).hasSize(2)
|
||||
assertThat(result.selectionArgs[0]).isEqualTo("%test%")
|
||||
assertThat(result.selectionArgs[1]).isEqualTo("%example.com%")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `should build correct SQL query for NOT operator with MESSAGE_CONTENTS field`() {
|
||||
// Arrange
|
||||
val condition = SearchCondition(MessageSearchField.MESSAGE_CONTENTS, SearchAttribute.CONTAINS, "test content")
|
||||
val node = SearchConditionTreeNode.Builder(condition)
|
||||
.not()
|
||||
.build()
|
||||
|
||||
// Act
|
||||
val result = SqlWhereClause.Builder()
|
||||
.withConditions(node)
|
||||
.build()
|
||||
|
||||
// Assert
|
||||
assertThat(result.selection).isEqualTo(
|
||||
"NOT (messages.id IN (SELECT docid FROM messages_fulltext WHERE fulltext MATCH ?))",
|
||||
)
|
||||
assertThat(result.selectionArgs).hasSize(1)
|
||||
assertThat(result.selectionArgs[0]).isEqualTo("test content")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `should build correct SQL query for TEXT field type`() {
|
||||
// Arrange
|
||||
val textField = TestSearchField("test_text_field", SearchFieldType.TEXT)
|
||||
val condition = SearchCondition(textField, SearchAttribute.CONTAINS, "test value")
|
||||
val node = SearchConditionTreeNode.Builder(condition).build()
|
||||
|
||||
// Act
|
||||
val result = SqlWhereClause.Builder()
|
||||
.withConditions(node)
|
||||
.build()
|
||||
|
||||
// Assert
|
||||
assertThat(result.selection).isEqualTo("test_text_field LIKE ?")
|
||||
assertThat(result.selectionArgs).hasSize(1)
|
||||
assertThat(result.selectionArgs[0]).isEqualTo("%test value%")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `should build correct SQL query for NUMBER field type with EQUALS attribute`() {
|
||||
// Arrange
|
||||
val numberField = TestSearchField("test_number_field", SearchFieldType.NUMBER)
|
||||
val condition = SearchCondition(numberField, SearchAttribute.EQUALS, "42")
|
||||
val node = SearchConditionTreeNode.Builder(condition).build()
|
||||
|
||||
// Act
|
||||
val result = SqlWhereClause.Builder()
|
||||
.withConditions(node)
|
||||
.build()
|
||||
|
||||
// Assert
|
||||
assertThat(result.selection).isEqualTo("test_number_field = ?")
|
||||
assertThat(result.selectionArgs).hasSize(1)
|
||||
assertThat(result.selectionArgs[0]).isEqualTo("42")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `should build correct SQL query for NUMBER field type with NOT_EQUALS attribute`() {
|
||||
// Arrange
|
||||
val numberField = TestSearchField("test_number_field", SearchFieldType.NUMBER)
|
||||
val condition = SearchCondition(numberField, SearchAttribute.NOT_EQUALS, "42")
|
||||
val node = SearchConditionTreeNode.Builder(condition).build()
|
||||
|
||||
// Act
|
||||
val result = SqlWhereClause.Builder()
|
||||
.withConditions(node)
|
||||
.build()
|
||||
|
||||
// Assert
|
||||
assertThat(result.selection).isEqualTo("test_number_field != ?")
|
||||
assertThat(result.selectionArgs).hasSize(1)
|
||||
assertThat(result.selectionArgs[0]).isEqualTo("42")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `should build correct SQL query for CUSTOM field type with custom query template`() {
|
||||
// Arrange
|
||||
val customField = TestSearchField(
|
||||
fieldName = "test_custom_field",
|
||||
fieldType = SearchFieldType.CUSTOM,
|
||||
customQueryTemplate = "custom_table.id IN (SELECT id FROM custom_table WHERE custom_column MATCH ?)",
|
||||
)
|
||||
val condition = SearchCondition(customField, SearchAttribute.CONTAINS, "custom value")
|
||||
val node = SearchConditionTreeNode.Builder(condition).build()
|
||||
|
||||
// Act
|
||||
val result = SqlWhereClause.Builder()
|
||||
.withConditions(node)
|
||||
.build()
|
||||
|
||||
// Assert
|
||||
assertThat(result.selection)
|
||||
.isEqualTo("custom_table.id IN (SELECT id FROM custom_table WHERE custom_column MATCH ?)")
|
||||
assertThat(result.selectionArgs).hasSize(1)
|
||||
assertThat(result.selectionArgs[0]).isEqualTo("custom value")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `should throw exception for CUSTOM field type without custom query template`() {
|
||||
// Arrange
|
||||
val customField = TestSearchField(
|
||||
fieldName = "test_custom_field",
|
||||
fieldType = SearchFieldType.CUSTOM,
|
||||
customQueryTemplate = null,
|
||||
)
|
||||
val condition = SearchCondition(customField, SearchAttribute.CONTAINS, "custom value")
|
||||
val node = SearchConditionTreeNode.Builder(condition).build()
|
||||
|
||||
// Act & Assert
|
||||
assertFailure {
|
||||
SqlWhereClause.Builder()
|
||||
.withConditions(node)
|
||||
.build()
|
||||
}.isInstanceOf<IllegalArgumentException>()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `should throw exception for CUSTOM field type with empty custom query template`() {
|
||||
// Arrange
|
||||
val customField = TestSearchField(
|
||||
fieldName = "test_custom_field",
|
||||
fieldType = SearchFieldType.CUSTOM,
|
||||
customQueryTemplate = "",
|
||||
)
|
||||
val condition = SearchCondition(customField, SearchAttribute.CONTAINS, "custom value")
|
||||
val node = SearchConditionTreeNode.Builder(condition).build()
|
||||
|
||||
// Act & Assert
|
||||
assertFailure {
|
||||
SqlWhereClause.Builder()
|
||||
.withConditions(node)
|
||||
.build()
|
||||
}.isInstanceOf<IllegalArgumentException>()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `should throw exception for CUSTOM field type with non-CONTAINS attribute`() {
|
||||
// Arrange
|
||||
val customField = TestSearchField(
|
||||
fieldName = "test_custom_field",
|
||||
fieldType = SearchFieldType.CUSTOM,
|
||||
customQueryTemplate = "custom_query",
|
||||
)
|
||||
val condition = SearchCondition(customField, SearchAttribute.EQUALS, "custom value")
|
||||
val node = SearchConditionTreeNode.Builder(condition).build()
|
||||
|
||||
// Act & Assert
|
||||
assertFailure {
|
||||
SqlWhereClause.Builder()
|
||||
.withConditions(node)
|
||||
.build()
|
||||
}.isInstanceOf<IllegalArgumentException>()
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue