Loading feature/search/src/main/java/net/thunderbird/feature/search/SearchConditionTreeNode.kt +50 −18 Original line number Diff line number Diff line Loading @@ -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 * Loading @@ -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 Loading @@ -46,6 +61,7 @@ class SearchConditionTreeNode private constructor( ) : Parcelable { enum class Operator { AND, NOT, OR, CONDITION, } Loading @@ -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"})" } } Loading @@ -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)) } Loading feature/search/src/test/java/net/thunderbird/feature/search/SearchConditionTreeNodeTest.kt +21 −0 Original line number Diff line number Diff line Loading @@ -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) } } legacy/core/src/main/java/com/fsck/k9/search/SqlQueryBuilder.java +5 −0 Original line number Diff line number Diff line Loading @@ -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(") "); Loading legacy/core/src/test/java/com/fsck/k9/search/SqlQueryBuilderTest.kt 0 → 100644 +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") } } Loading
feature/search/src/main/java/net/thunderbird/feature/search/SearchConditionTreeNode.kt +50 −18 Original line number Diff line number Diff line Loading @@ -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 * Loading @@ -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 Loading @@ -46,6 +61,7 @@ class SearchConditionTreeNode private constructor( ) : Parcelable { enum class Operator { AND, NOT, OR, CONDITION, } Loading @@ -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"})" } } Loading @@ -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)) } Loading
feature/search/src/test/java/net/thunderbird/feature/search/SearchConditionTreeNodeTest.kt +21 −0 Original line number Diff line number Diff line Loading @@ -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) } }
legacy/core/src/main/java/com/fsck/k9/search/SqlQueryBuilder.java +5 −0 Original line number Diff line number Diff line Loading @@ -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(") "); Loading
legacy/core/src/test/java/com/fsck/k9/search/SqlQueryBuilderTest.kt 0 → 100644 +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") } }