Repo created

This commit is contained in:
Fr4nz D13trich 2025-11-22 13:56:56 +01:00
parent 75dc487a7a
commit 39c29d175b
6317 changed files with 388324 additions and 2 deletions

View 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)
}
}
}

View file

@ -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(")")
}
}

View file

@ -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,
)
}
}
}

View file

@ -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
}
}
}

View file

@ -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),
}

View file

@ -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
}

View file

@ -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)
}
}

View file

@ -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,
}

View file

@ -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,
)

View file

@ -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?
}

View file

@ -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,
}

View file

@ -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)
}
}

View file

@ -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)
}
}

View file

@ -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
}
}
}

View file

@ -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("")
}
}

View file

@ -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>()
}
}

View file

@ -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)
}
}

View file

@ -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>()
}
}