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

Unverified Commit 6f805c3f authored by Wolf-Martell Montwé's avatar Wolf-Martell Montwé
Browse files

refactor(search): change SearchConditionTreeNode to builder pattern and add test

parent f2c86aa1
Loading
Loading
Loading
Loading
+3 −3
Original line number Diff line number Diff line
@@ -40,8 +40,8 @@ internal class UnifiedFolderRepositoryTest {
        val search = messageCountsProvider.recordedSearch
        assertThat(search.id).isEqualTo("unified_inbox")
        val condition = search.conditions.condition
        assertThat(condition.value).isEqualTo("1")
        assertThat(condition.attribute).isEqualTo(SearchAttribute.EQUALS)
        assertThat(condition.field).isEqualTo(SearchField.INTEGRATE)
        assertThat(condition?.value).isEqualTo("1")
        assertThat(condition?.attribute).isEqualTo(SearchAttribute.EQUALS)
        assertThat(condition?.field).isEqualTo(SearchField.INTEGRATE)
    }
}
+3 −3
Original line number Diff line number Diff line
@@ -40,8 +40,8 @@ internal class UnifiedFolderRepositoryTest {
        val search = messageCountsProvider.recordedSearch
        assertThat(search.id).isEqualTo("unified_inbox")
        val condition = search.conditions.condition
        assertThat(condition.value).isEqualTo("1")
        assertThat(condition.attribute).isEqualTo(SearchAttribute.EQUALS)
        assertThat(condition.field).isEqualTo(SearchField.INTEGRATE)
        assertThat(condition?.value).isEqualTo("1")
        assertThat(condition?.attribute).isEqualTo(SearchAttribute.EQUALS)
        assertThat(condition?.field).isEqualTo(SearchField.INTEGRATE)
    }
}
+13 −7
Original line number Diff line number Diff line
@@ -91,7 +91,7 @@ public class LocalMessageSearch implements MessageSearchSpecification {
     * @return New top AND node, new root.
     */
    public SearchConditionTreeNode and(SearchCondition condition) {
        SearchConditionTreeNode tmp = new SearchConditionTreeNode(condition);
        SearchConditionTreeNode tmp = new SearchConditionTreeNode.Builder(condition).build();
        return and(tmp);
    }

@@ -110,7 +110,10 @@ public class LocalMessageSearch implements MessageSearchSpecification {
            return node;
        }

        mConditions = mConditions.and(node);
        mConditions = new SearchConditionTreeNode.Builder(mConditions)
                .and(node)
                .build();

        return mConditions;
    }

@@ -122,7 +125,7 @@ public class LocalMessageSearch implements MessageSearchSpecification {
     * @return New top OR node, new root.
     */
    public SearchConditionTreeNode or(SearchCondition condition) {
        SearchConditionTreeNode tmp = new SearchConditionTreeNode(condition);
        SearchConditionTreeNode tmp = new SearchConditionTreeNode.Builder(condition).build();
        return or(tmp);
    }

@@ -141,7 +144,10 @@ public class LocalMessageSearch implements MessageSearchSpecification {
            return node;
        }

        mConditions = mConditions.or(node);
        mConditions = new SearchConditionTreeNode.Builder(mConditions)
                .or(node)
                .build();

        return mConditions;
    }

@@ -170,9 +176,9 @@ public class LocalMessageSearch implements MessageSearchSpecification {
    public List<Long> getFolderIds() {
        List<Long> results = new ArrayList<>();
        for (SearchConditionTreeNode node : mLeafSet) {
            if (node.condition.field == SearchField.FOLDER &&
                    node.condition.attribute == SearchAttribute.EQUALS) {
                results.add(Long.valueOf(node.condition.value));
            if (node.getCondition().field == SearchField.FOLDER &&
                    node.getCondition().attribute == SearchAttribute.EQUALS) {
                results.add(Long.valueOf(node.getCondition().value));
            }
        }
        return results;
+83 −208
Original line number Diff line number Diff line
package net.thunderbird.feature.search

import android.os.Parcel
import android.os.Parcelable
import kotlinx.parcelize.Parcelize
import net.thunderbird.feature.search.api.SearchCondition

/**
 * This class stores search conditions. It's basically a boolean expression binary tree.
 * The output will be SQL queries ( obtained by traversing inorder ).
 * Represents a node in a boolean expression tree for evaluating search conditions.
 *
 * TODO removing conditions from the tree
 * TODO implement NOT as a node again
 */
class SearchConditionTreeNode : Parcelable {
    enum class Operator {
        AND,
        OR,
        CONDITION,
    }

    @JvmField
    var mLeft: SearchConditionTreeNode? = null

    @JvmField
    var mRight: SearchConditionTreeNode? = null
    var mParent: SearchConditionTreeNode?

    /*
     * If mValue isn't CONDITION then mCondition contains a real
     * condition, otherwise it's null.
     */
    @JvmField
    var mValue: Operator
    var condition: SearchCondition?

    constructor(condition: SearchCondition?) {
        mParent = null
        this.condition = condition
        mValue = Operator.CONDITION
    }

    constructor(parent: SearchConditionTreeNode?, op: Operator) {
        mParent = parent
        mValue = op
        this.condition = null
    }

    /**
     * Adds the expression as the second argument of an AND
     * clause to this node.
 * This data structure is used to construct complex logical queries by combining
 * simple `SearchCondition` objects using logical operators like `AND` and `OR`.
 *
     * @param expr Expression to 'AND' with.
     * @ return New top AND node.
     */
    fun and(expr: SearchConditionTreeNode): SearchConditionTreeNode {
        return add(expr, Operator.AND)
    }

    /**
     * Convenience method.
     * Adds the provided condition as the second argument of an AND
     * clause to this node.
 * 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
 *
     * @param condition Condition to 'AND' with.
     * @return New top AND node, new root.
     */
    fun and(condition: SearchCondition?): SearchConditionTreeNode {
        val tmp = SearchConditionTreeNode(condition)
        return and(tmp)
    }

    /**
     * Adds the expression as the second argument of an OR
     * clause to this node.
 * Example tree:
 *
     * @param expr Expression to 'OR' with.
     * @return New top OR node.
     */
    fun or(expr: SearchConditionTreeNode): SearchConditionTreeNode {
        return add(expr, Operator.OR)
    }

    /**
     * Convenience method.
     * Adds the provided condition as the second argument of an OR
     * clause to this node.
 *      OR
 *     /  \
 *   AND   CONDITION(subject CONTAINS "invoice")
 *  /   \
 * A     B
 *
     * @param condition Condition to 'OR' with.
     * @return New top OR node, new root.
     */
    fun or(condition: SearchCondition?): SearchConditionTreeNode {
        val tmp = SearchConditionTreeNode(condition)
        return or(tmp)
    }

    /**
     * Returns the condition stored in this node.
     * @ return Condition stored in the node.
     */
    val leafSet: MutableSet<SearchConditionTreeNode?>
        /**
         * Get a set of all the leaves in the tree.
         * @return Set of all the leaves.
         */
        get() {
            val leafSet: MutableSet<SearchConditionTreeNode?> =
                HashSet<SearchConditionTreeNode?>()
            return getLeafSet(leafSet)
        }

    /**
     * Adds two new ConditionTreeNodes, one for the operator and one for the
     * new condition. The current node will end up on the same level as the
     * one provided in the arguments, they will be siblings. Their common
     * parent node will be one containing the operator provided in the arguments.
     * The method will update all the required references so the tree ends up in
     * a valid state.
 * Where:
 * - A = CONDITION(from CONTAINS "bob@example.com")
 * - B = CONDITION(to CONTAINS "alice@example.com")
 *
     * This method only supports node arguments with a null parent node.
 * Represents logic:
 *   (from CONTAINS "bob@example.com" AND to CONTAINS "alice@example.com")
 *   OR subject CONTAINS "invoice"
 *
     * @param node Node to add.
     * @param op Operator that will connect the new node with this one.
     * @ return New parent node, containing the operator .
     * @throws IllegalArgumentException Throws when the provided new node does not have a null parent.
     */
    private fun add(node: SearchConditionTreeNode, op: Operator): SearchConditionTreeNode {
        require(node.mParent == null) { "Can only add new expressions from root node down." }

        val tmpNode = SearchConditionTreeNode(mParent, op)
        tmpNode.mLeft = this
        tmpNode.mRight = node

        if (mParent != null) {
            mParent!!.updateChild(this, tmpNode)
        }

        this.mParent = tmpNode
        node.mParent = tmpNode

        return tmpNode
    }

    /**
     * Helper method that replaces a child of the current node with a new node.
     * If the provided old child node was the left one, left will be replaced with
     * the new one. Same goes for the right one.
 * Use `getLeafSet()` to extract all base conditions for analysis or UI rendering.
 *
 *  TODO implement NOT as a node again
 *
     * @param oldChild Old child node to be replaced.
     * @param newChild New child node.
 * @see SearchCondition
 * @see LocalMessageSearch
 */
    private fun updateChild(oldChild: SearchConditionTreeNode?, newChild: SearchConditionTreeNode?) {
        // we can compare objects id's because this is the desired behaviour in this case
        if (mLeft === oldChild) {
            mLeft = newChild
        } else if (mRight === oldChild) {
            mRight = newChild
        }
@Parcelize
class SearchConditionTreeNode private constructor(
    val operator: Operator,
    val condition: SearchCondition? = null,
    var left: SearchConditionTreeNode? = null,
    var right: SearchConditionTreeNode? = null,
) : Parcelable {
    enum class Operator {
        AND,
        OR,
        CONDITION,
    }

    /**
     * Recursive function to gather all the leaves in the subtree of which
     * this node is the root.
     *
     * @param leafSet Leafset that's being built.
     * @return Set of leaves being completed.
     */
    private fun getLeafSet(leafSet: MutableSet<SearchConditionTreeNode?>): MutableSet<SearchConditionTreeNode?> {
        if (mLeft == null && mRight == null) {
            // if we ended up in a leaf, add ourself and return
            leafSet.add(this)
    fun getLeafSet(): Set<SearchConditionTreeNode> {
        val leafSet = mutableSetOf<SearchConditionTreeNode>()
        collectLeaves(this, leafSet)
        return leafSet
    }

        // we didn't end up in a leaf
        if (mLeft != null) {
            mLeft!!.getLeafSet(leafSet)
        }
    private fun collectLeaves(node: SearchConditionTreeNode?, leafSet: MutableSet<SearchConditionTreeNode>) {
        if (node == null) return

        if (mRight != null) {
            mRight!!.getLeafSet(leafSet)
        if (node.left == null && node.right == null) {
            leafSet.add(node)
        } else {
            collectLeaves(node.left, leafSet)
            collectLeaves(node.right, leafSet)
        }
        return leafSet
    }

    /**/
    // ///////////////////////////////////////////////////////// */ // Parcelable
    //
    // This whole class has to be parcelable because it's passed
    // on through intents.
    /**/
    // ///////////////////////////////////////////////////////// */
    override fun describeContents(): Int {
        return 0
    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)"
            }
        }

    override fun writeToParcel(dest: Parcel, flags: Int) {
        dest.writeInt(mValue.ordinal)
        dest.writeParcelable(this.condition, flags)
        dest.writeParcelable(mLeft, flags)
        dest.writeParcelable(mRight, flags)
    }

    private constructor(`in`: Parcel) {
        mValue = Operator.entries[`in`.readInt()]
        this.condition = `in`.readParcelable<SearchCondition?>(SearchConditionTreeNode::class.java.getClassLoader())
        mLeft = `in`.readParcelable<SearchConditionTreeNode?>(SearchConditionTreeNode::class.java.getClassLoader())
        mRight = `in`.readParcelable<SearchConditionTreeNode?>(SearchConditionTreeNode::class.java.getClassLoader())
        mParent = null
    class Builder(
        private var root: SearchConditionTreeNode,
    ) {

        if (mLeft != null) {
            mLeft!!.mParent = this
        }
        constructor(condition: SearchCondition) : this(SearchConditionTreeNode(Operator.CONDITION, condition))

        if (mRight != null) {
            mRight!!.mParent = this
        }
        fun and(condition: SearchCondition): Builder {
            return and(SearchConditionTreeNode(Operator.CONDITION, condition))
        }

    override fun toString(): String {
        return "ConditionsTreeNode(" +
            "mLeft=" + mLeft +
            ", mRight=" + mRight +
            ", mParent=" + mParent +
            ", mValue=" + mValue +
            ", mCondition=" + this.condition +
            ')'
        fun and(node: SearchConditionTreeNode): Builder {
            root = SearchConditionTreeNode(
                operator = Operator.AND,
                left = root,
                right = node,
            )
            return this
        }

    companion object {
        @JvmField
        val CREATOR: Parcelable.Creator<SearchConditionTreeNode?> =
            object : Parcelable.Creator<SearchConditionTreeNode?> {
                override fun createFromParcel(`in`: Parcel): SearchConditionTreeNode {
                    return SearchConditionTreeNode(`in`)
        fun or(condition: SearchCondition): Builder {
            return or(SearchConditionTreeNode(Operator.CONDITION, condition))
        }

                override fun newArray(size: Int): Array<SearchConditionTreeNode?> {
                    return arrayOfNulls<SearchConditionTreeNode>(size)
        fun or(node: SearchConditionTreeNode): Builder {
            root = SearchConditionTreeNode(
                operator = Operator.OR,
                left = root,
                right = node,
            )
            return this
        }

        fun build(): SearchConditionTreeNode {
            return root
        }
    }
}
+147 −0
Original line number Diff line number Diff line
package net.thunderbird.feature.search

import assertk.assertThat
import assertk.assertions.contains
import assertk.assertions.isEqualTo
import assertk.assertions.isNotNull
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 SearchConditionTreeNodeTest {

    @Test
    fun `should create a node with a condition`() {
        // Arrange
        val condition = SearchCondition(SearchField.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(SearchField.SUBJECT, SearchAttribute.CONTAINS, "test")
        val condition2 = SearchCondition(SearchField.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(SearchField.SUBJECT, SearchAttribute.CONTAINS, "test")
        val condition2 = SearchCondition(SearchField.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(SearchField.SUBJECT, SearchAttribute.CONTAINS, "test")
        val condition2 = SearchCondition(SearchField.SENDER, SearchAttribute.CONTAINS, "example.com")
        val condition3 = SearchCondition(SearchField.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(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)
            .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)
    }
}
Loading