Donate to e Foundation | Murena handsets with /e/OS | Own a part of Murena! Learn more

Unverified Commit 9b959b56 authored by Wolf-Martell Montwé's avatar Wolf-Martell Montwé
Browse files

feat(search): add not operation to SearchConditionTreeNode

parent 6f805c3f
Loading
Loading
Loading
Loading
+50 −18
Original line number Diff line number Diff line
@@ -7,18 +7,23 @@ import net.thunderbird.feature.search.api.SearchCondition
/**
 * Represents a node in a boolean expression tree for evaluating search conditions.
 *
 * This data structure is used to construct complex logical queries by combining
 * simple `SearchCondition` objects using logical operators like `AND` and `OR`.
 * This tree is used to construct logical queries by combining simple {@link SearchCondition}
 * leaf nodes using logical operators: AND, OR, and NOT.
 *
 * Each node in the tree is one of:
 * - A leaf node: `operator == CONDITION`, contains a single `SearchCondition`
 * - An internal node: `operator == AND` or `OR`, with left and right children
 * 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
 *            /  \
 *   AND   CONDITION(subject CONTAINS "invoice")
 *         NOT   CONDITION(subject contains "invoice")
 *          |
 *        AND
 *       /   \
 *      A    B
 *
@@ -27,12 +32,22 @@ import net.thunderbird.feature.search.api.SearchCondition
 * - B = CONDITION(to CONTAINS "alice@example.com")
 *
 * Represents logic:
 *   (from CONTAINS "bob@example.com" AND to CONTAINS "alice@example.com")
 *   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.
 *
 *  TODO implement NOT as a node again
 * 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
@@ -46,6 +61,7 @@ class SearchConditionTreeNode private constructor(
) : Parcelable {
    enum class Operator {
        AND,
        NOT,
        OR,
        CONDITION,
    }
@@ -59,22 +75,30 @@ class SearchConditionTreeNode private constructor(
    private fun collectLeaves(node: SearchConditionTreeNode?, leafSet: MutableSet<SearchConditionTreeNode>) {
        if (node == null) return

        if (node.left == null && node.right == null) {
            leafSet.add(node)
        } else {
        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.CONDITION -> condition.toString()
            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"})"
        }
    }

@@ -97,6 +121,14 @@ class SearchConditionTreeNode private constructor(
            return this
        }

        fun not(): Builder {
            root = SearchConditionTreeNode(
                operator = Operator.NOT,
                left = root,
            )
            return this
        }

        fun or(condition: SearchCondition): Builder {
            return or(SearchConditionTreeNode(Operator.CONDITION, condition))
        }
+21 −0
Original line number Diff line number Diff line
@@ -144,4 +144,25 @@ class SearchConditionTreeNodeTest {
        assertThat(conditions).contains(condition2)
        assertThat(conditions).contains(condition3)
    }

    @Test
    fun `should create a node with NOT operator`() {
        // Arrange
        val condition = SearchCondition(SearchField.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)
    }
}
+5 −0
Original line number Diff line number Diff line
@@ -34,7 +34,12 @@ public class SqlQueryBuilder {
            } else {
                appendCondition(condition, query, selectionArgs);
            }
        } else if (node.getOperator() == SearchConditionTreeNode.Operator.NOT) {
            query.append("NOT (");
            buildWhereClauseInternal(node.getLeft(), query, selectionArgs);
            query.append(")");
        } else {
            // Handle binary operators (AND, OR)
            query.append("(");
            buildWhereClauseInternal(node.getLeft(), query, selectionArgs);
            query.append(") ");
+162 −0
Original line number Diff line number Diff line
package com.fsck.k9.search

import assertk.assertThat
import assertk.assertions.hasSize
import assertk.assertions.isEqualTo
import net.thunderbird.feature.search.SearchConditionTreeNode
import net.thunderbird.feature.search.api.SearchAttribute
import net.thunderbird.feature.search.api.SearchCondition
import net.thunderbird.feature.search.api.SearchField
import org.junit.Test

class SqlQueryBuilderTest {

    @Test
    fun `should build correct SQL query for NOT operator`() {
        // Arrange
        val condition = SearchCondition(SearchField.SUBJECT, SearchAttribute.CONTAINS, "test")
        val node = SearchConditionTreeNode.Builder(condition)
            .not()
            .build()

        val query = StringBuilder()
        val selectionArgs = mutableListOf<String>()

        // Act
        SqlQueryBuilder.buildWhereClause(node, query, selectionArgs)

        // Assert
        assertThat(query.toString()).isEqualTo("NOT (subject LIKE ?)")
        assertThat(selectionArgs).hasSize(1)
        assertThat(selectionArgs[0]).isEqualTo("%test%")
    }

    @Test
    fun `should build correct SQL query for complex expression with NOT operator`() {
        // Arrange
        val condition1 = SearchCondition(SearchField.SUBJECT, SearchAttribute.CONTAINS, "test")
        val condition2 = SearchCondition(SearchField.SENDER, SearchAttribute.CONTAINS, "example.com")

        val node = SearchConditionTreeNode.Builder(condition1)
            .and(condition2)
            .not()
            .build()

        val query = StringBuilder()
        val selectionArgs = mutableListOf<String>()

        // Act
        SqlQueryBuilder.buildWhereClause(node, query, selectionArgs)

        // Assert
        assertThat(query.toString()).isEqualTo("NOT ((subject LIKE ?) AND (sender_list LIKE ?))")
        assertThat(selectionArgs).hasSize(2)
        assertThat(selectionArgs[0]).isEqualTo("%test%")
        assertThat(selectionArgs[1]).isEqualTo("%example.com%")
    }

    @Test
    fun `should build correct SQL query for NOT operator combined with AND`() {
        // Arrange
        val condition1 = SearchCondition(SearchField.SUBJECT, SearchAttribute.CONTAINS, "test")
        val condition2 = SearchCondition(SearchField.SENDER, SearchAttribute.CONTAINS, "example.com")
        val condition3 = SearchCondition(SearchField.FLAGGED, SearchAttribute.EQUALS, "1")

        val node = SearchConditionTreeNode.Builder(condition1)
            .not()
            .and(condition2)
            .and(condition3)
            .build()

        val query = StringBuilder()
        val selectionArgs = mutableListOf<String>()

        // Act
        SqlQueryBuilder.buildWhereClause(node, query, selectionArgs)

        // Assert
        assertThat(query.toString()).isEqualTo("((NOT (subject LIKE ?)) AND (sender_list LIKE ?)) AND (flagged = ?)")
        assertThat(selectionArgs).hasSize(3)
        assertThat(selectionArgs[0]).isEqualTo("%test%")
        assertThat(selectionArgs[1]).isEqualTo("%example.com%")
        assertThat(selectionArgs[2]).isEqualTo("1")
    }

    @Test
    fun `should build correct SQL query for NOT operator combined with OR`() {
        // Arrange
        val condition1 = SearchCondition(SearchField.SUBJECT, SearchAttribute.CONTAINS, "test")
        val condition2 = SearchCondition(SearchField.SENDER, SearchAttribute.CONTAINS, "example.com")
        val condition3 = SearchCondition(SearchField.FLAGGED, SearchAttribute.EQUALS, "1")

        val node = SearchConditionTreeNode.Builder(condition1)
            .not()
            .or(condition2)
            .or(condition3)
            .build()

        val query = StringBuilder()
        val selectionArgs = mutableListOf<String>()

        // Act
        SqlQueryBuilder.buildWhereClause(node, query, selectionArgs)

        // Assert
        assertThat(query.toString()).isEqualTo("((NOT (subject LIKE ?)) OR (sender_list LIKE ?)) OR (flagged = ?)")
        assertThat(selectionArgs).hasSize(3)
        assertThat(selectionArgs[0]).isEqualTo("%test%")
        assertThat(selectionArgs[1]).isEqualTo("%example.com%")
        assertThat(selectionArgs[2]).isEqualTo("1")
    }

    @Test
    fun `should build correct SQL query for multiple NOT operators`() {
        // Arrange
        val condition1 = SearchCondition(SearchField.SUBJECT, SearchAttribute.CONTAINS, "test")
        val condition2 = SearchCondition(SearchField.SENDER, SearchAttribute.CONTAINS, "example.com")

        val node = SearchConditionTreeNode.Builder(condition1)
            .not()
            .and(
                SearchConditionTreeNode.Builder(condition2)
                    .not()
                    .build(),
            )
            .build()

        val query = StringBuilder()
        val selectionArgs = mutableListOf<String>()

        // Act
        SqlQueryBuilder.buildWhereClause(node, query, selectionArgs)

        // Assert
        assertThat(query.toString()).isEqualTo("(NOT (subject LIKE ?)) AND (NOT (sender_list LIKE ?))")
        assertThat(selectionArgs).hasSize(2)
        assertThat(selectionArgs[0]).isEqualTo("%test%")
        assertThat(selectionArgs[1]).isEqualTo("%example.com%")
    }

    @Test
    fun `should build correct SQL query for NOT operator with MESSAGE_CONTENTS field`() {
        // Arrange
        val condition = SearchCondition(SearchField.MESSAGE_CONTENTS, SearchAttribute.CONTAINS, "test content")

        val node = SearchConditionTreeNode.Builder(condition)
            .not()
            .build()

        val query = StringBuilder()
        val selectionArgs = mutableListOf<String>()

        // Act
        SqlQueryBuilder.buildWhereClause(node, query, selectionArgs)

        // Assert
        assertThat(query.toString()).isEqualTo(
            "NOT (messages.id IN (SELECT docid FROM messages_fulltext WHERE fulltext MATCH ?))",
        )
        assertThat(selectionArgs).hasSize(1)
        assertThat(selectionArgs[0]).isEqualTo("test content")
    }
}