From a595b160dbc402200d906c67509404620e2206d4 Mon Sep 17 00:00:00 2001 From: Leandro Colmenarez <89696212+LeandroLCD@users.noreply.github.com> Date: Mon, 17 Nov 2025 20:37:42 -0300 Subject: [PATCH 01/40] feat(Query): Automatically use the first logical operation as the WHERE clause This commit introduces a convenience feature where the first logical operation (`AND` or `OR`) is automatically used as the `WHERE` clause if one is not explicitly set in the query builder. This simplifies query construction by removing the requirement to call `where()` first. The changes include: - Modifying the `build()` method in `QuerySelect.Builder` and `QueryDelete.Builder` to automatically identify and set the `WHERE` clause from the existing logical operations. - Updating `getSqlOperators()` in `QuerySelect`, `QueryDelete`, and `QueryUpdate` to correctly include the `where` operator in the returned list, improving query introspection. --- .../java/com/blipblipcode/query/QueryDelete.kt | 15 ++++++++++++--- .../java/com/blipblipcode/query/QuerySelect.kt | 14 +++++++++++--- .../java/com/blipblipcode/query/QueryUpdate.kt | 2 +- 3 files changed, 24 insertions(+), 7 deletions(-) diff --git a/query/src/main/java/com/blipblipcode/query/QueryDelete.kt b/query/src/main/java/com/blipblipcode/query/QueryDelete.kt index 75cb19c..9de5d0d 100644 --- a/query/src/main/java/com/blipblipcode/query/QueryDelete.kt +++ b/query/src/main/java/com/blipblipcode/query/QueryDelete.kt @@ -64,8 +64,9 @@ class QueryDelete private constructor( } override fun getSqlOperators(): List> { - return operations.values.map { - it.operator + return buildList { + add(where) + operations.values.forEach { add(it.operator) } } } @@ -203,7 +204,15 @@ class QueryDelete private constructor( * @throws IllegalArgumentException if the WHERE clause is not set. */ fun build(): QueryDelete { - require(where != null) { "A WHERE clause must be specified." } + if(where == null){ + val w = operations.firstNotNullOfOrNull{it}.let { + it ?: throw IllegalArgumentException("A WHERE clause must be specified.") + } + operations.remove(w.key) + + where = w.value.operator + } + return QueryDelete( where = where!!, table = table, operations = operations ) diff --git a/query/src/main/java/com/blipblipcode/query/QuerySelect.kt b/query/src/main/java/com/blipblipcode/query/QuerySelect.kt index 29423d6..3ef0e0d 100644 --- a/query/src/main/java/com/blipblipcode/query/QuerySelect.kt +++ b/query/src/main/java/com/blipblipcode/query/QuerySelect.kt @@ -88,8 +88,9 @@ class QuerySelect private constructor( } override fun getSqlOperators(): List> { - return operations.values.map { - it.operator + return buildList { + add(where) + operations.values.forEach { add(it.operator) } } } @@ -319,7 +320,14 @@ class QuerySelect private constructor( * @throws IllegalArgumentException if the WHERE clause is not set. */ fun build(): QuerySelect { - require(where != null) { "A WHERE clause must be specified." } + if(where == null){ + val w = operations.firstNotNullOfOrNull{it}.let { + it ?: throw IllegalArgumentException("WHERE clause is required for QuerySelect") + } + operations.remove(w.key) + + where = w.value.operator + } return QuerySelect( where = where!!, table = table, diff --git a/query/src/main/java/com/blipblipcode/query/QueryUpdate.kt b/query/src/main/java/com/blipblipcode/query/QueryUpdate.kt index a5b85d2..c43398d 100644 --- a/query/src/main/java/com/blipblipcode/query/QueryUpdate.kt +++ b/query/src/main/java/com/blipblipcode/query/QueryUpdate.kt @@ -75,7 +75,7 @@ class QueryUpdate private constructor( } override fun getSqlOperators(): List> { - return emptyList() + return listOf(where) } override fun getTableName(): String { From 20e71ace6078aba2bd7589f61b069f8b2e479aeb Mon Sep 17 00:00:00 2001 From: Leandro Colmenarez <89696212+LeandroLCD@users.noreply.github.com> Date: Mon, 17 Nov 2025 20:46:22 -0300 Subject: [PATCH 02/40] test(QueryDelete): Fix test for building without a WHERE clause This commit corrects a test case for `QueryDelete`. The test `build without where clause throws exception` was incorrectly including a WHERE condition, which prevented it from accurately verifying that building a `QueryDelete` without any conditions throws an `IllegalArgumentException`. The extraneous condition has been removed. --- query/src/test/java/com/blipblipcode/query/QueryDeleteTest.kt | 1 - 1 file changed, 1 deletion(-) diff --git a/query/src/test/java/com/blipblipcode/query/QueryDeleteTest.kt b/query/src/test/java/com/blipblipcode/query/QueryDeleteTest.kt index d54a8d0..fb0d7bf 100644 --- a/query/src/test/java/com/blipblipcode/query/QueryDeleteTest.kt +++ b/query/src/test/java/com/blipblipcode/query/QueryDeleteTest.kt @@ -43,7 +43,6 @@ class QueryDeleteTest { @Test fun `build without where clause throws exception`() { val builder = QueryDelete.builder("users") - .and("status", SQLOperator.Equals("status", "active")) assertThrows(IllegalArgumentException::class.java) { builder.build() From 6edf9d84cb0ec2b75ba01582a345590a54821c53 Mon Sep 17 00:00:00 2001 From: Leandro Colmenarez <89696212+LeandroLCD@users.noreply.github.com> Date: Mon, 17 Nov 2025 20:59:50 -0300 Subject: [PATCH 03/40] Update query/src/main/java/com/blipblipcode/query/QueryDelete.kt Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../main/java/com/blipblipcode/query/QueryDelete.kt | 10 +--------- 1 file changed, 1 insertion(+), 9 deletions(-) diff --git a/query/src/main/java/com/blipblipcode/query/QueryDelete.kt b/query/src/main/java/com/blipblipcode/query/QueryDelete.kt index 9de5d0d..239cec6 100644 --- a/query/src/main/java/com/blipblipcode/query/QueryDelete.kt +++ b/query/src/main/java/com/blipblipcode/query/QueryDelete.kt @@ -204,15 +204,7 @@ class QueryDelete private constructor( * @throws IllegalArgumentException if the WHERE clause is not set. */ fun build(): QueryDelete { - if(where == null){ - val w = operations.firstNotNullOfOrNull{it}.let { - it ?: throw IllegalArgumentException("A WHERE clause must be specified.") - } - operations.remove(w.key) - - where = w.value.operator - } - + require(where != null) { "A WHERE clause must be specified." } return QueryDelete( where = where!!, table = table, operations = operations ) From cd823c11c5a3a9e56a7758718e8646f58ad8f7c9 Mon Sep 17 00:00:00 2001 From: Leandro Colmenarez <89696212+LeandroLCD@users.noreply.github.com> Date: Mon, 17 Nov 2025 21:00:07 -0300 Subject: [PATCH 04/40] Update query/src/main/java/com/blipblipcode/query/QuerySelect.kt Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../src/main/java/com/blipblipcode/query/QuerySelect.kt | 9 +-------- 1 file changed, 1 insertion(+), 8 deletions(-) diff --git a/query/src/main/java/com/blipblipcode/query/QuerySelect.kt b/query/src/main/java/com/blipblipcode/query/QuerySelect.kt index 3ef0e0d..73a1ac9 100644 --- a/query/src/main/java/com/blipblipcode/query/QuerySelect.kt +++ b/query/src/main/java/com/blipblipcode/query/QuerySelect.kt @@ -320,14 +320,7 @@ class QuerySelect private constructor( * @throws IllegalArgumentException if the WHERE clause is not set. */ fun build(): QuerySelect { - if(where == null){ - val w = operations.firstNotNullOfOrNull{it}.let { - it ?: throw IllegalArgumentException("WHERE clause is required for QuerySelect") - } - operations.remove(w.key) - - where = w.value.operator - } + require(where != null) { "WHERE clause is required for QuerySelect" } return QuerySelect( where = where!!, table = table, From 0ef87ec3d2132ab40ae2aca170c49ca250cfac39 Mon Sep 17 00:00:00 2001 From: Leandro Colmenarez <89696212+LeandroLCD@users.noreply.github.com> Date: Tue, 18 Nov 2025 11:58:10 -0300 Subject: [PATCH 05/40] feat(QuerySelect): Allow queries without a WHERE clause This commit modifies `QuerySelect` to support SQL queries without a `WHERE` clause. Previously, the `WHERE` clause was mandatory. This restriction has been lifted, but the requirement is maintained if other logical operations (like `AND` or `OR`) are present. The main changes include: - The `where` property in `QuerySelect` is now nullable (`SQLOperator<*>?`). - The `build()` method in the `QuerySelect.Builder` now only requires a `WHERE` clause if subsequent logical operations are added. - The SQL generation in `asSql()` has been updated to correctly build the `SELECT` statement when no `WHERE` clause is provided. - `getSqlOperators()` is updated to handle the nullable `where` property. --- .../java/com/blipblipcode/query/QuerySelect.kt | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/query/src/main/java/com/blipblipcode/query/QuerySelect.kt b/query/src/main/java/com/blipblipcode/query/QuerySelect.kt index 73a1ac9..8d4d770 100644 --- a/query/src/main/java/com/blipblipcode/query/QuerySelect.kt +++ b/query/src/main/java/com/blipblipcode/query/QuerySelect.kt @@ -17,7 +17,7 @@ import com.blipblipcode.query.operator.SQLOperator * @property fields The list of columns to be returned in the result set. Defaults to "*". */ class QuerySelect private constructor( - private var where: SQLOperator<*>, + private var where: SQLOperator<*>?, private val table: String, private val operations: LinkedHashMap, private val fields: List @@ -89,7 +89,7 @@ class QuerySelect private constructor( override fun getSqlOperators(): List> { return buildList { - add(where) + where?.let { add(it) } operations.values.forEach { add(it.operator) } } } @@ -110,7 +110,11 @@ class QuerySelect private constructor( val fieldStr = if (fields.isEmpty()) "*" else fields.joinToString(", ") val operationsStr = if (operations.isNotEmpty()) operations.values.joinToString(" ") { it.asString() } else "" return buildString { - append("SELECT $fieldStr FROM $table WHERE ${where.toSQLString()} $operationsStr".trim()) + if (where == null) { + append("SELECT $fieldStr FROM $table") + }else{ + append("SELECT $fieldStr FROM $table WHERE ${where?.toSQLString()} $operationsStr".trim()) + } if (orderBy != null) { append(" ") append(orderBy!!.asString()) @@ -320,9 +324,11 @@ class QuerySelect private constructor( * @throws IllegalArgumentException if the WHERE clause is not set. */ fun build(): QuerySelect { - require(where != null) { "WHERE clause is required for QuerySelect" } + if(operations.isNotEmpty()){ + require(where != null) { "WHERE clause is required for QuerySelect" } + } return QuerySelect( - where = where!!, + where = where, table = table, operations = LinkedHashMap(operations), fields = fields From ac5b7fb118309a57967a1680fe5c767deb5abf4e Mon Sep 17 00:00:00 2001 From: Leandro Colmenarez <89696212+LeandroLCD@users.noreply.github.com> Date: Thu, 20 Nov 2025 11:50:57 -0300 Subject: [PATCH 06/40] chore(deps): Update AGP to 8.13.1 Updates the Android Gradle Plugin version from 8.13.0 to 8.13.1. feat(Query): Enhance `orderBy` and fix `like` operator This commit introduces several improvements to the query builder, focusing on `ORDER BY` clause handling and correcting the behavior of the `like` operator. Key changes include: * **Fix `like` Operator Logic**: The `like()` method in `QuerySelect` and `QueryDelete` builders was incorrectly using a `LIKE` logical type. This has been corrected to use the `AND` logical type, ensuring it chains correctly with other conditions. The method signature is now restricted to `SQLOperator.Like`. * **Enhanced `OrderBy` in Union and Join Queries**: `UnionQuery` and `InnerJoint` now correctly handle `ORDER BY` clauses. They aggregate `OrderBy` operators from all subqueries, clear them from the individual queries, and apply a combined `OrderBy` to the final result set. This prevents SQL errors and ensures predictable sorting. `QuerySelect` has been updated with `getOrderBy()` and nullable `orderBy()` methods to support this. * **Improved `OrderBy` Implementation**: * A new `OrderBy.Multiple` sealed class has been added to handle sorting by multiple columns with different sort directions. * The `asSqlClause()` method was introduced for more flexible SQL string generation. * **Extensive Testing**: Added comprehensive unit tests for the `orderBy` functionality in `QuerySelectTest`, covering various scenarios like single and multiple columns, mixed sort directions, aliases, and combinations with `LIMIT`. --- gradle/libs.versions.toml | 2 +- .../java/com/blipblipcode/query/InnerJoint.kt | 10 +- .../com/blipblipcode/query/QueryDelete.kt | 4 +- .../com/blipblipcode/query/QuerySelect.kt | 14 +- .../java/com/blipblipcode/query/UnionQuery.kt | 11 +- .../query/operator/LogicalType.kt | 2 - .../blipblipcode/query/operator/OrdenBy.kt | 21 + .../com/blipblipcode/query/QuerySelectTest.kt | 370 ++++++++++++++++++ 8 files changed, 421 insertions(+), 13 deletions(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 219338e..e768210 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,5 +1,5 @@ [versions] -agp = "8.13.0" +agp = "8.13.1" kotlin = "2.0.21" coreKtx = "1.17.0" junit = "4.13.2" diff --git a/query/src/main/java/com/blipblipcode/query/InnerJoint.kt b/query/src/main/java/com/blipblipcode/query/InnerJoint.kt index 2964e96..0739c76 100644 --- a/query/src/main/java/com/blipblipcode/query/InnerJoint.kt +++ b/query/src/main/java/com/blipblipcode/query/InnerJoint.kt @@ -38,6 +38,12 @@ class InnerJoint private constructor( override fun asSql(): String { require(queries.isNotEmpty()) { "At least one query is required for an INNER JOIN" } require(queries.size == onClauses.size) { "The number of queries must be equal to the number of ON clauses (including a placeholder for the base query)" } + val orders = queries.fold(mutableListOf()) { acc, query -> + query.getOrderBy()?.let { acc.add(it) } + query.orderBy(null) + acc + } + orders.addAll(orderBy?.let { listOf(it) } ?: emptyList()) val baseQuery = queries.first() val joins = queries.drop(1).zip(onClauses.drop(1)) { query, onClause -> @@ -46,9 +52,9 @@ class InnerJoint private constructor( return buildString { append("${baseQuery.asSql()} ${joins.joinToString(" ")}") - if (orderBy != null) { + if (orders.isNotEmpty()) { appendLine() - append(orderBy!!.asString()) + append(OrderBy.Multiple(orders).asString()) } } } diff --git a/query/src/main/java/com/blipblipcode/query/QueryDelete.kt b/query/src/main/java/com/blipblipcode/query/QueryDelete.kt index 239cec6..aa51715 100644 --- a/query/src/main/java/com/blipblipcode/query/QueryDelete.kt +++ b/query/src/main/java/com/blipblipcode/query/QueryDelete.kt @@ -162,8 +162,8 @@ class QueryDelete private constructor( * @param operator The SQL operator for this condition. * @return The `QueryBuilder` instance for chaining. */ - fun like(key: String, operator: SQLOperator<*>): QueryBuilder { - operations[key] = LogicalOperation(LogicalType.LIKE, operator) + fun like(key: String, operator: SQLOperator.Like): QueryBuilder { + operations[key] = LogicalOperation(LogicalType.AND, operator) return this } diff --git a/query/src/main/java/com/blipblipcode/query/QuerySelect.kt b/query/src/main/java/com/blipblipcode/query/QuerySelect.kt index 8d4d770..aab6cd6 100644 --- a/query/src/main/java/com/blipblipcode/query/QuerySelect.kt +++ b/query/src/main/java/com/blipblipcode/query/QuerySelect.kt @@ -87,6 +87,7 @@ class QuerySelect private constructor( } } + override fun getSqlOperators(): List> { return buildList { where?.let { add(it) } @@ -133,7 +134,7 @@ class QuerySelect private constructor( * @param operator A vararg of `[OrderBy]` objects specifying the columns and direction for sorting. * @return A new `QuerySelect` instance representing the UNION query with the added ORDER BY clause. */ - fun orderBy(operator: OrderBy): Queryable { + fun orderBy(operator: OrderBy?): Queryable { orderBy = operator return this } @@ -161,6 +162,10 @@ class QuerySelect private constructor( return this } + fun getOrderBy(): OrderBy? { + return this.orderBy + } + /** * A builder for creating `QuerySelect` instances. @@ -241,8 +246,8 @@ class QuerySelect private constructor( * @param operator The SQL operator for this condition. * @return The `QueryBuilder` instance for chaining. */ - fun like(key: String, operator: SQLOperator<*>): QueryBuilder { - operations[key] = LogicalOperation(LogicalType.LIKE, operator) + fun like(key: String, operator: SQLOperator.Like): QueryBuilder { + operations[key] = LogicalOperation(LogicalType.AND, operator) return this } @@ -296,6 +301,9 @@ class QuerySelect private constructor( this.orderBy = orderBy return this } + fun getOrderBy(): OrderBy? { + return this.orderBy + } /** * Sets a LIMIT clause for the query to limit the number of rows returned. diff --git a/query/src/main/java/com/blipblipcode/query/UnionQuery.kt b/query/src/main/java/com/blipblipcode/query/UnionQuery.kt index 477ce39..973c2b3 100644 --- a/query/src/main/java/com/blipblipcode/query/UnionQuery.kt +++ b/query/src/main/java/com/blipblipcode/query/UnionQuery.kt @@ -35,14 +35,19 @@ class UnionQuery private constructor( */ override fun asSql(): String { require(queries.size >= 2) { "At least two queries are required for a UNION" } - + val orders = queries.fold(mutableListOf()) { acc, query -> + query.getOrderBy()?.let { acc.add(it) } + query.orderBy(null) + acc + } + orders.addAll(orderBy?.let { listOf(it) } ?: emptyList()) val unionKeyword = if (useUnionAll) "UNION ALL" else "UNION" return buildString { append(queries.joinToString("\n$unionKeyword\n") { it.asSql() }) - if (orderBy != null) { + if (orders.isNotEmpty()) { appendLine() - append(orderBy!!.asString()) + append(OrderBy.Multiple(orders).asString()) } } } diff --git a/query/src/main/java/com/blipblipcode/query/operator/LogicalType.kt b/query/src/main/java/com/blipblipcode/query/operator/LogicalType.kt index 06f0903..8ad523e 100644 --- a/query/src/main/java/com/blipblipcode/query/operator/LogicalType.kt +++ b/query/src/main/java/com/blipblipcode/query/operator/LogicalType.kt @@ -8,8 +8,6 @@ enum class LogicalType(val sql: String) { AND("AND"), /** Represents a logical OR operation. */ OR("OR"), - /** Represents a SQL LIKE operation. */ - LIKE("LIKE"), /** Represents a SQL ALL operation. */ ALL("ALL"), diff --git a/query/src/main/java/com/blipblipcode/query/operator/OrdenBy.kt b/query/src/main/java/com/blipblipcode/query/operator/OrdenBy.kt index beea5a6..efc7f91 100644 --- a/query/src/main/java/com/blipblipcode/query/operator/OrdenBy.kt +++ b/query/src/main/java/com/blipblipcode/query/operator/OrdenBy.kt @@ -3,6 +3,14 @@ package com.blipblipcode.query.operator sealed interface OrderBy { val column: String fun asString(): String + + fun asSqlClause(): String { + return when (this) { + is Asc -> "$column ASC" + is Desc -> "$column DESC" + is Multiple -> orders.joinToString(", ") { it.asSqlClause() } + } + } data class Asc(override val column: String) : OrderBy{ override fun asString(): String { return "ORDER BY $column ASC" @@ -19,4 +27,17 @@ sealed interface OrderBy { return "ORDER BY $column DESC" } } + + data class Multiple(val orders: List) : OrderBy{ + override val column: String + get() = orders.joinToString(", ") { it.column } + + override fun asString(): String { + return "ORDER BY ${asSqlClause()}" + } + + override fun toString(): String { + return "ORDER BY ${asSqlClause()}" + } + } } \ No newline at end of file diff --git a/query/src/test/java/com/blipblipcode/query/QuerySelectTest.kt b/query/src/test/java/com/blipblipcode/query/QuerySelectTest.kt index cba561d..4477095 100644 --- a/query/src/test/java/com/blipblipcode/query/QuerySelectTest.kt +++ b/query/src/test/java/com/blipblipcode/query/QuerySelectTest.kt @@ -2,6 +2,7 @@ package com.blipblipcode.query import com.blipblipcode.query.operator.LogicalOperation import com.blipblipcode.query.operator.LogicalType +import com.blipblipcode.query.operator.OrderBy import com.blipblipcode.query.operator.SQLOperator import com.blipblipcode.query.utils.asSQLiteQuery import org.junit.Assert.assertEquals @@ -358,4 +359,373 @@ class QuerySelectTest { val expectedSql = "SELECT * FROM users WHERE status = 'active' LIMIT 10 OFFSET -5" assertEquals(expectedSql, query.asSql().trim()) } + + @Test + fun `orderBy with ascending order`() { + val query = QuerySelect.builder("users") + .where(SQLOperator.Equals("status", "active")) + .build() + query.orderBy(OrderBy.Asc("name")) + val expectedSql = "SELECT * FROM users WHERE status = 'active' ORDER BY name ASC" + assertEquals(expectedSql, query.asSql().trim()) + } + + @Test + fun `orderBy with descending order`() { + val query = QuerySelect.builder("users") + .where(SQLOperator.Equals("status", "active")) + .build() + query.orderBy(OrderBy.Desc("created_at")) + val expectedSql = "SELECT * FROM users WHERE status = 'active' ORDER BY created_at DESC" + assertEquals(expectedSql, query.asSql().trim()) + } + + @Test + fun `orderBy with multiple columns ascending`() { + val query = QuerySelect.builder("users") + .where(SQLOperator.Equals("status", "active")) + .build() + query.orderBy(OrderBy.Multiple(listOf( + OrderBy.Asc("name"), + OrderBy.Asc("age") + ))) + val expectedSql = "SELECT * FROM users WHERE status = 'active' ORDER BY name ASC, age ASC" + assertEquals(expectedSql, query.asSql().trim()) + } + + @Test + fun `orderBy with multiple columns mixed directions`() { + val query = QuerySelect.builder("users") + .where(SQLOperator.Equals("status", "active")) + .build() + query.orderBy(OrderBy.Multiple(listOf( + OrderBy.Asc("status"), + OrderBy.Desc("created_at") + ))) + val expectedSql = "SELECT * FROM users WHERE status = 'active' ORDER BY status ASC, created_at DESC" + assertEquals(expectedSql, query.asSql().trim()) + } + + @Test + fun `orderBy with descending then ascending`() { + val query = QuerySelect.builder("users") + .where(SQLOperator.Equals("status", "active")) + .build() + query.orderBy(OrderBy.Multiple(listOf( + OrderBy.Desc("priority"), + OrderBy.Asc("name") + ))) + val expectedSql = "SELECT * FROM users WHERE status = 'active' ORDER BY priority DESC, name ASC" + assertEquals(expectedSql, query.asSql().trim()) + } + + @Test + fun `orderBy with ascending and descending columns`() { + val query = QuerySelect.builder("users") + .where(SQLOperator.Equals("status", "active")) + .build() + query.orderBy(OrderBy.Multiple(listOf( + OrderBy.Asc("department"), + OrderBy.Desc("salary") + ))) + val expectedSql = "SELECT * FROM users WHERE status = 'active' ORDER BY department ASC, salary DESC" + assertEquals(expectedSql, query.asSql().trim()) + } + + @Test + fun `orderBy with limit and ascending order`() { + val query = QuerySelect.builder("users") + .where(SQLOperator.Equals("status", "active")) + .limit(10) + .build() + query.orderBy(OrderBy.Asc("name")) + val expectedSql = "SELECT * FROM users WHERE status = 'active' ORDER BY name ASC LIMIT 10" + assertEquals(expectedSql, query.asSql().trim()) + } + + @Test + fun `orderBy with limit and descending order`() { + val query = QuerySelect.builder("users") + .where(SQLOperator.Equals("status", "active")) + .limit(5, 10) + .build() + query.orderBy(OrderBy.Desc("created_at")) + val expectedSql = "SELECT * FROM users WHERE status = 'active' ORDER BY created_at DESC LIMIT 5 OFFSET 10" + assertEquals(expectedSql, query.asSql().trim()) + } + + @Test + fun `orderBy chaining call`() { + val query = QuerySelect.builder("users") + .where(SQLOperator.Equals("status", "active")) + .build() + val instance = query.orderBy(OrderBy.Asc("name")) + assertEquals(query, instance) + } + + @Test + fun `orderBy replacing previous order`() { + val query = QuerySelect.builder("users") + .where(SQLOperator.Equals("status", "active")) + .build() + query.orderBy(OrderBy.Desc("created_at")) + query.orderBy(OrderBy.Asc("name")) + val expectedSql = "SELECT * FROM users WHERE status = 'active' ORDER BY name ASC" + assertEquals(expectedSql, query.asSql().trim()) + } + + @Test + fun `orderBy with null value`() { + val query = QuerySelect.builder("users") + .where(SQLOperator.Equals("status", "active")) + .build() + query.orderBy(null) + val expectedSql = "SELECT * FROM users WHERE status = 'active'" + assertEquals(expectedSql, query.asSql().trim()) + } + + @Test + fun `orderBy with three columns mixed directions`() { + val query = QuerySelect.builder("users") + .where(SQLOperator.Equals("status", "active")) + .build() + query.orderBy(OrderBy.Multiple(listOf( + OrderBy.Asc("department"), + OrderBy.Desc("created_at"), + OrderBy.Asc("name") + ))) + val expectedSql = "SELECT * FROM users WHERE status = 'active' ORDER BY department ASC, created_at DESC, name ASC" + assertEquals(expectedSql, query.asSql().trim()) + } + + @Test + fun `orderBy with special characters in column names`() { + val query = QuerySelect.builder("users") + .where(SQLOperator.Equals("status", "active")) + .build() + query.orderBy(OrderBy.Asc("`first name`")) + val expectedSql = "SELECT * FROM users WHERE status = 'active' ORDER BY `first name` ASC" + assertEquals(expectedSql, query.asSql().trim()) + } + + @Test + fun `orderBy with multiple columns and special characters`() { + val query = QuerySelect.builder("`user table`") + .where(SQLOperator.Equals("`user id`", 1)) + .build() + query.orderBy(OrderBy.Multiple(listOf( + OrderBy.Asc("`first name`"), + OrderBy.Desc("`last name`") + ))) + val expectedSql = "SELECT * FROM `user table` WHERE `user id` = 1 ORDER BY `first name` ASC, `last name` DESC" + assertEquals(expectedSql, query.asSql().trim()) + } + + @Test + fun `orderBy ascending with multiple logical operations`() { + val query = QuerySelect.builder("users") + .where(SQLOperator.Equals("id", 1)) + .and("status", SQLOperator.Equals("status", "active")) + .or("role", SQLOperator.Equals("role", "admin")) + .build() + query.orderBy(OrderBy.Asc("created_at")) + val expectedSql = "SELECT * FROM users WHERE id = 1 AND status = 'active' OR role = 'admin' ORDER BY created_at ASC" + assertEquals(expectedSql, query.asSql()) + } + + @Test + fun `orderBy descending with multiple logical operations`() { + val query = QuerySelect.builder("users") + .where(SQLOperator.Equals("id", 1)) + .and("status", SQLOperator.Equals("status", "active")) + .or("role", SQLOperator.Equals("role", "admin")) + .build() + query.orderBy(OrderBy.Desc("created_at")) + val expectedSql = "SELECT * FROM users WHERE id = 1 AND status = 'active' OR role = 'admin' ORDER BY created_at DESC" + assertEquals(expectedSql, query.asSql()) + } + + @Test + fun `orderBy multiple with specific fields`() { + val query = QuerySelect.builder("users") + .where(SQLOperator.Equals("status", "active")) + .setFields("name", "email", "created_at") + .build() + query.orderBy(OrderBy.Multiple(listOf( + OrderBy.Asc("name"), + OrderBy.Desc("created_at") + ))) + val expectedSql = "SELECT name, email, created_at FROM users WHERE status = 'active' ORDER BY name ASC, created_at DESC" + assertEquals(expectedSql, query.asSql().trim()) + } + + @Test + fun `setFields with single field alias`() { + val query = QuerySelect.builder("users") + .where(SQLOperator.Equals("id", 1)) + .setFields("name AS full_name") + .build() + val expectedSql = "SELECT name AS full_name FROM users WHERE id = 1" + assertEquals(expectedSql, query.asSql().trim()) + } + + @Test + fun `setFields with multiple fields with aliases`() { + val query = QuerySelect.builder("users") + .where(SQLOperator.Equals("status", "active")) + .setFields("name AS full_name", "email AS user_email", "created_at AS registration_date") + .build() + val expectedSql = "SELECT name AS full_name, email AS user_email, created_at AS registration_date FROM users WHERE status = 'active'" + assertEquals(expectedSql, query.asSql().trim()) + } + + @Test + fun `setFields with mixed fields and aliases`() { + val query = QuerySelect.builder("users") + .where(SQLOperator.Equals("status", "active")) + .setFields("id", "name AS full_name", "email") + .build() + val expectedSql = "SELECT id, name AS full_name, email FROM users WHERE status = 'active'" + assertEquals(expectedSql, query.asSql().trim()) + } + + @Test + fun `setFields with function and alias`() { + val query = QuerySelect.builder("users") + .where(SQLOperator.Equals("status", "active")) + .setFields("COUNT(*) AS total_users", "name AS user_name") + .build() + val expectedSql = "SELECT COUNT(*) AS total_users, name AS user_name FROM users WHERE status = 'active'" + assertEquals(expectedSql, query.asSql().trim()) + } + + @Test + fun `setFields with uppercase alias`() { + val query = QuerySelect.builder("users") + .where(SQLOperator.Equals("id", 1)) + .setFields("name AS NAME", "email AS EMAIL") + .build() + val expectedSql = "SELECT name AS NAME, email AS EMAIL FROM users WHERE id = 1" + assertEquals(expectedSql, query.asSql().trim()) + } + + @Test + fun `setFields with backtick quoted alias`() { + val query = QuerySelect.builder("users") + .where(SQLOperator.Equals("id", 1)) + .setFields("`name` AS `full name`", "`email` AS `user email`") + .build() + val expectedSql = "SELECT `name` AS `full name`, `email` AS `user email` FROM users WHERE id = 1" + assertEquals(expectedSql, query.asSql().trim()) + } + + @Test + fun `setFields with table prefix and alias`() { + val query = QuerySelect.builder("users") + .where(SQLOperator.Equals("users.id", 1)) + .setFields("users.name AS full_name", "users.email AS user_email") + .build() + val expectedSql = "SELECT users.name AS full_name, users.email AS user_email FROM users WHERE users.id = 1" + assertEquals(expectedSql, query.asSql().trim()) + } + + @Test + fun `setFields with aliases and orderBy`() { + val query = QuerySelect.builder("users") + .where(SQLOperator.Equals("status", "active")) + .setFields("name AS full_name", "created_at AS registration_date") + .build() + query.orderBy(OrderBy.Asc("full_name")) + val expectedSql = "SELECT name AS full_name, created_at AS registration_date FROM users WHERE status = 'active' ORDER BY full_name ASC" + assertEquals(expectedSql, query.asSql().trim()) + } + + @Test + fun `setFields with aliases and limit`() { + val query = QuerySelect.builder("users") + .where(SQLOperator.Equals("status", "active")) + .setFields("name AS full_name", "email AS user_email") + .limit(10) + .build() + val expectedSql = "SELECT name AS full_name, email AS user_email FROM users WHERE status = 'active' LIMIT 10" + assertEquals(expectedSql, query.asSql().trim()) + } + + @Test + fun `setFields with aliases orderBy and limit combined`() { + val query = QuerySelect.builder("users") + .where(SQLOperator.Equals("status", "active")) + .setFields("name AS full_name", "created_at AS registration_date", "email AS user_email") + .limit(5, 10) + .build() + query.orderBy(OrderBy.Multiple(listOf( + OrderBy.Asc("registration_date"), + OrderBy.Desc("full_name") + ))) + val expectedSql = "SELECT name AS full_name, created_at AS registration_date, email AS user_email FROM users WHERE status = 'active' ORDER BY registration_date ASC, full_name DESC LIMIT 5 OFFSET 10" + assertEquals(expectedSql, query.asSql().trim()) + } + + @Test + fun `setFields with CASE statement and alias`() { + val query = QuerySelect.builder("users") + .where(SQLOperator.Equals("id", 1)) + .setFields("CASE WHEN status = 'active' THEN 'Active User' ELSE 'Inactive' END AS user_status") + .build() + val expectedSql = "SELECT CASE WHEN status = 'active' THEN 'Active User' ELSE 'Inactive' END AS user_status FROM users WHERE id = 1" + assertEquals(expectedSql, query.asSql().trim()) + } + + @Test + fun `setFields with aggregate functions and aliases`() { + val query = QuerySelect.builder("users") + .where(SQLOperator.Equals("status", "active")) + .setFields("COUNT(*) AS total", "SUM(salary) AS total_salary", "AVG(salary) AS average_salary") + .build() + val expectedSql = "SELECT COUNT(*) AS total, SUM(salary) AS total_salary, AVG(salary) AS average_salary FROM users WHERE status = 'active'" + assertEquals(expectedSql, query.asSql().trim()) + } + + @Test + fun `setFields with mathematical expression and alias`() { + val query = QuerySelect.builder("products") + .where(SQLOperator.Equals("category", "electronics")) + .setFields("name", "price", "price * 0.1 AS discount_amount") + .build() + val expectedSql = "SELECT name, price, price * 0.1 AS discount_amount FROM products WHERE category = 'electronics'" + assertEquals(expectedSql, query.asSql().trim()) + } + + @Test + fun `setFields with alias immutability`() { + val originalQuery = QuerySelect.builder("users") + .where(SQLOperator.Equals("id", 1)) + .build() + val originalSql = originalQuery.asSql() + + originalQuery.setFields("name AS full_name", "email AS user_email") + + assertEquals(originalSql, originalQuery.asSql()) + } + + @Test + fun `setFields replacing previous fields with aliases`() { + val query = QuerySelect.builder("users") + .where(SQLOperator.Equals("status", "active")) + .setFields("id", "name") + .build() + val newQuery = query.setFields("name AS full_name", "email AS user_email", "created_at AS registration_date") + val expectedSql = "SELECT name AS full_name, email AS user_email, created_at AS registration_date FROM users WHERE status = 'active'" + assertEquals(expectedSql, newQuery.asSql().trim()) + } + + @Test + fun `setFields with multiple aliases using builder`() { + val query = QuerySelect.builder("employees") + .where(SQLOperator.Equals("department", "sales")) + .setFields("employee_id AS id", "first_name AS fname", "last_name AS lname", "salary AS monthly_salary") + .build() + val expectedSql = "SELECT employee_id AS id, first_name AS fname, last_name AS lname, salary AS monthly_salary FROM employees WHERE department = 'sales'" + assertEquals(expectedSql, query.asSql().trim()) + } } From c0b92b62ff46da799da3ad2efa7c6efcf9d6d704 Mon Sep 17 00:00:00 2001 From: Leandro Colmenarez <89696212+LeandroLCD@users.noreply.github.com> Date: Thu, 20 Nov 2025 11:52:17 -0300 Subject: [PATCH 07/40] docs(UnionQuery): Fix KDoc for orderBy parameter This commit corrects the KDoc for the `orderBy` function in `UnionQuery`. The `@param` name has been updated from `columns` to `operator` to accurately match the function's parameter name. --- query/src/main/java/com/blipblipcode/query/UnionQuery.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/query/src/main/java/com/blipblipcode/query/UnionQuery.kt b/query/src/main/java/com/blipblipcode/query/UnionQuery.kt index 973c2b3..57244dc 100644 --- a/query/src/main/java/com/blipblipcode/query/UnionQuery.kt +++ b/query/src/main/java/com/blipblipcode/query/UnionQuery.kt @@ -56,7 +56,7 @@ class UnionQuery private constructor( * Appends an ORDER BY clause to the entire UNION query. * Note that in most SQL dialects, an ORDER BY clause can only be applied to the final result of a UNION, not to individual `SELECT` statements within it. * - * @param columns A vararg of `OrderExpression` objects specifying the columns and direction for sorting. + * @param operator A vararg of `OrderExpression` objects specifying the columns and direction for sorting. * @return A new `QuerySelect` instance representing the UNION query with the added ORDER BY clause. */ fun orderBy(operator: OrderBy): Queryable { From 664d910534c0cbdf95a8b04639ae4874946903c5 Mon Sep 17 00:00:00 2001 From: Leandro Colmenarez <89696212+LeandroLCD@users.noreply.github.com> Date: Wed, 26 Nov 2025 11:09:03 -0300 Subject: [PATCH 08/40] feat(Query): Add case-insensitive comparison support This commit introduces support for case-insensitive comparisons in SQL operators by adding an optional `CaseConversion` parameter. This allows wrapping columns and values with `LOWER()` or `UPPER()` SQL functions. The main changes include: * **New `CaseConversion` Enum**: A new enum `CaseConversion` (`NONE`, `LOWER`, `UPPER`) has been created to specify the desired case conversion. * **Updated `SQLOperator`**: * The `SQLOperator` interface and all its implementing data classes now include a `caseConversion` property, which defaults to `NONE`. * The `toSQLString()` methods have been updated to apply the corresponding SQL function (`LOWER()` or `UPPER()`) to both the column and the value when a conversion is specified. * **New Test Cases**: Added tests to `QuerySelectTest` to verify the correct SQL generation for queries using `UPPER` case conversion in the `WHERE` clause. --- .../query/operator/CaseConversion.kt | 14 ++++ .../query/operator/SQLOperator.kt | 77 ++++++++++++++----- .../com/blipblipcode/query/QuerySelectTest.kt | 19 +++++ 3 files changed, 89 insertions(+), 21 deletions(-) create mode 100644 query/src/main/java/com/blipblipcode/query/operator/CaseConversion.kt diff --git a/query/src/main/java/com/blipblipcode/query/operator/CaseConversion.kt b/query/src/main/java/com/blipblipcode/query/operator/CaseConversion.kt new file mode 100644 index 0000000..25ca80f --- /dev/null +++ b/query/src/main/java/com/blipblipcode/query/operator/CaseConversion.kt @@ -0,0 +1,14 @@ +package com.blipblipcode.query.operator + +enum class CaseConversion { + NONE, + LOWER, + UPPER; + fun asSqlFunction(value: Any): String { + return when (this) { + NONE -> value.toString() + LOWER -> "LOWER($value)" + UPPER -> "UPPER($value)" + } + } +} \ No newline at end of file diff --git a/query/src/main/java/com/blipblipcode/query/operator/SQLOperator.kt b/query/src/main/java/com/blipblipcode/query/operator/SQLOperator.kt index 8637140..f0654f1 100644 --- a/query/src/main/java/com/blipblipcode/query/operator/SQLOperator.kt +++ b/query/src/main/java/com/blipblipcode/query/operator/SQLOperator.kt @@ -11,6 +11,8 @@ sealed interface SQLOperator { val column: String val value: T + val caseConversion: CaseConversion + /** * Returns a `Pair` of the column name and its value. */ @@ -26,67 +28,95 @@ sealed interface SQLOperator { is String -> "'$value'" else -> value.toString() } - return "$column $symbol $valueStr" + return "${caseConversion.asSqlFunction(column)} $symbol ${caseConversion.asSqlFunction(valueStr)}" } /** * Provides a simple string representation of the operator. */ - fun asString(): String = "$column $symbol $value" + fun asString(): String = "${caseConversion.asSqlFunction(column)} $symbol ${caseConversion.asSqlFunction(value.toString())}" /** Represents an "=" operation. */ - data class Equals(override val column: String, override val value: T) : SQLOperator { + data class Equals( + override val column: String, + override val value: T, + override val caseConversion: CaseConversion = CaseConversion.NONE + ) : SQLOperator { override val symbol: String = "=" } /** Represents a "!=" operation. */ - data class NotEquals(override val column: String, override val value: T) : SQLOperator { + data class NotEquals( + override val column: String, + override val value: T, + override val caseConversion: CaseConversion = CaseConversion.NONE) : SQLOperator { override val symbol: String = "!=" } /** Represents a ">" operation. */ - data class GreaterThan(override val column: String, override val value: T) : SQLOperator { + data class GreaterThan( + override val column: String, + override val value: T, + override val caseConversion: CaseConversion = CaseConversion.NONE) : SQLOperator { override val symbol: String = ">" } /** Represents a "<" operation. */ - data class LessThan(override val column: String, override val value: T) : SQLOperator { + data class LessThan( + override val column: String, + override val value: T, + override val caseConversion: CaseConversion = CaseConversion.NONE) : SQLOperator { override val symbol: String = "<" } /** Represents a ">=" operation. */ - data class GreaterThanOrEqual(override val column: String, override val value: T) : SQLOperator { + data class GreaterThanOrEqual( + override val column: String, + override val value: T, + override val caseConversion: CaseConversion = CaseConversion.NONE) : SQLOperator { override val symbol: String = ">=" } /** Represents a "<=" operation. */ - data class LessThanOrEqual(override val column: String, override val value: T) : SQLOperator { + data class LessThanOrEqual( + override val column: String, + override val value: T, + override val caseConversion: CaseConversion = CaseConversion.NONE) : SQLOperator { override val symbol: String = "<=" } /** Represents a "LIKE" operation. */ - data class Like(override val column: String, override val value: String) : SQLOperator { + data class Like( + override val column: String, + override val value: String, + override val caseConversion: CaseConversion = CaseConversion.NONE) : SQLOperator { override val symbol: String = "LIKE" override fun toSQLString(): String { - return "$column $symbol '%$value%'" + return "${caseConversion.asSqlFunction(column)} $symbol ${caseConversion.asSqlFunction("'%$value%'")}" } } /** Represents an "IN" operation. */ - data class In(override val column: String, override val value: List) : SQLOperator> { + data class In( + override val column: String, + override val value: List, + override val caseConversion: CaseConversion = CaseConversion.NONE) : SQLOperator> { override val symbol: String = "IN" override fun toSQLString(): String { - val list = value.joinToString(", ") { if (it is String) "'$it'" else it.toString() } - return "$column $symbol ($list)" + val list = value.joinToString(", ") { caseConversion.asSqlFunction(it.toString()) } + return "${caseConversion.asSqlFunction(column)} $symbol ($list)" } } /** Represents a "NOT IN" operation. */ - data class NotIn(override val column: String, override val value: List) : SQLOperator> { + data class NotIn( + override val column: String, + override val value: List, + override val caseConversion: CaseConversion = CaseConversion.NONE) : SQLOperator> { override val symbol: String = "NOT IN" override fun toSQLString(): String { - val list = value.joinToString(", ") { if (it is String) "'$it'" else it.toString() } - return "$column $symbol ($list)" + val list = value.joinToString(", ") { caseConversion.asSqlFunction(it.toString())} + return "${caseConversion.asSqlFunction(column)} $symbol ($list)" } } @@ -94,6 +124,7 @@ sealed interface SQLOperator { data class IsNull(override val column: String) : SQLOperator { override val symbol: String = "IS NULL" override val value: Unit = Unit + override val caseConversion: CaseConversion = CaseConversion.NONE override fun toSQLString(): String = "$column $symbol" override fun asString(): String = "$column $symbol" } @@ -102,19 +133,23 @@ sealed interface SQLOperator { data class IsNotNull(override val column: String) : SQLOperator { override val symbol: String = "IS NOT NULL" override val value: Unit = Unit + override val caseConversion: CaseConversion = CaseConversion.NONE override fun toSQLString(): String = "$column $symbol" override fun asString(): String = "$column $symbol" } /** Represents a "BETWEEN" operation. */ - data class Between(override val column: String, val start: T, val end: T) : SQLOperator> { + data class Between( + override val column: String, + val start: T, val end: T, + override val caseConversion: CaseConversion = CaseConversion.NONE) : SQLOperator> { override val symbol: String = "BETWEEN" override val value: Pair = start to end override fun toSQLString(): String { - val startStr = if (start is String) "'$start'" else start.toString() - val endStr = if (end is String) "'$end'" else end.toString() - return "$column $symbol $startStr AND $endStr" + val startStr = caseConversion.asSqlFunction(start.toString()) + val endStr = caseConversion.asSqlFunction(end.toString()) + return "${caseConversion.asSqlFunction(column)} $symbol $startStr AND $endStr" } - override fun asString(): String = "$column $symbol $start AND $end" + override fun asString(): String = "${caseConversion.asSqlFunction(column)} $symbol ${caseConversion.asSqlFunction(start.toString())} AND ${caseConversion.asSqlFunction(start.toString())}" } } diff --git a/query/src/test/java/com/blipblipcode/query/QuerySelectTest.kt b/query/src/test/java/com/blipblipcode/query/QuerySelectTest.kt index 4477095..557a9b5 100644 --- a/query/src/test/java/com/blipblipcode/query/QuerySelectTest.kt +++ b/query/src/test/java/com/blipblipcode/query/QuerySelectTest.kt @@ -1,5 +1,6 @@ package com.blipblipcode.query +import com.blipblipcode.query.operator.CaseConversion import com.blipblipcode.query.operator.LogicalOperation import com.blipblipcode.query.operator.LogicalType import com.blipblipcode.query.operator.OrderBy @@ -195,6 +196,24 @@ class QuerySelectTest { assertEquals(expectedSql, query.asSql().trim()) } + @Test + fun `asSql with a basic WHERE `() { + val query = QuerySelect.builder("users") + .build() + val expectedSql = "SELECT * FROM users" + assertEquals(expectedSql, query.asSql().trim()) + } + + @Test + fun `asSql with a WHERE clause and uppercase`() { + val query = QuerySelect.builder("users") + .where(SQLOperator.Equals("id", 1)) + .and("status", SQLOperator.Equals("status", "active", caseConversion = CaseConversion.UPPER)) + .build() + val expectedSql = "SELECT * FROM users WHERE id = 1 AND UPPER(status) = UPPER('active')" + assertEquals(expectedSql, query.asSql()) + } + @Test fun `asSql with specific fields and no logical operations`() { val query = QuerySelect.builder("users") From 8bb0d5aa09c411562d6cc99979d973ede0cbc3ee Mon Sep 17 00:00:00 2001 From: Leandro Colmenarez <89696212+LeandroLCD@users.noreply.github.com> Date: Mon, 8 Dec 2025 17:15:34 -0300 Subject: [PATCH 09/40] chore(deps): Update various dependencies This commit updates the following dependencies to their latest versions: * Kotlin: `2.0.21` -> `2.2.21` * AndroidX Lifecycle Runtime KTX: `2.9.4` -> `2.10.0` * AndroidX Activity Compose: `1.11.0` -> `1.12.1` * AndroidX Compose BOM: `2024.09.00` -> `2025.12.00` * AndroidX SQLite KTX: `2.6.1` -> `2.6.2` --- gradle/libs.versions.toml | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index e768210..3ec804f 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,16 +1,16 @@ [versions] agp = "8.13.1" -kotlin = "2.0.21" +kotlin = "2.2.21" coreKtx = "1.17.0" junit = "4.13.2" junitVersion = "1.3.0" espressoCore = "3.7.0" -lifecycleRuntimeKtx = "2.9.4" -activityCompose = "1.11.0" -composeBom = "2024.09.00" +lifecycleRuntimeKtx = "2.10.0" +activityCompose = "1.12.1" +composeBom = "2025.12.00" appcompat = "1.7.1" material = "1.13.0" -sqliteKtx = "2.6.1" +sqliteKtx = "2.6.2" [libraries] androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } From c4c208f534f25af43b290ac1a00353ae256abfc1 Mon Sep 17 00:00:00 2001 From: Leandro Colmenarez <89696212+LeandroLCD@users.noreply.github.com> Date: Mon, 8 Dec 2025 17:15:49 -0300 Subject: [PATCH 10/40] chore(README): Update Kotlin version badge This commit updates the Kotlin version badge in the `README.md` file. The version has been changed from `1.9+` to `2.2.21+` to reflect the current Kotlin version used in the project. --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 9dfa137..6c7d1fc 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ ### Una librería de Kotlin ligera y fluida para construir consultas SQL de forma programática -[![Kotlin](https://img.shields.io/badge/Kotlin-1.9+-purple.svg?style=flat&logo=kotlin)](https://kotlinlang.org) +[![Kotlin](https://img.shields.io/badge/Kotlin-2.2.21+-purple.svg?style=flat&logo=kotlin)](https://kotlinlang.org) [![Android](https://img.shields.io/badge/Android-API%2021+-green.svg?style=flat&logo=android)](https://developer.android.com) [![Room](https://img.shields.io/badge/Room-Compatible-blue.svg?style=flat)](https://developer.android.com/training/data-storage/room) ![GitHub release (latest by date)](https://img.shields.io/github/v/release/LeandroLCD/android-sql-query) From 8b3be0db94a92c64994592b0b120db7da4475f30 Mon Sep 17 00:00:00 2001 From: Leandro Colmenarez <89696212+LeandroLCD@users.noreply.github.com> Date: Mon, 8 Dec 2025 17:16:04 -0300 Subject: [PATCH 11/40] refactor(Field): Implement SQLOperator for consistency This commit refactors the `Field` data class to implement the `SQLOperator` interface, standardizing its structure with other operators. The main changes include: - Implementing `SQLOperator` in `Field`, providing `symbol`, `column`, `value`, and `caseConversion` properties. - Updating the `asString()` method to use the newly added `symbol` property for SQL generation. - Adding a `clone()` method to the `OrderBy` sealed class and its subclasses (`Asc`, `Desc`, `Multiple`) to allow for deep cloning of instances, which improves immutability and flexibility in query construction. - Fixing the `IN` and `NOT IN` operators in `SQLOperator.kt` by ensuring that string values within the list are correctly quoted in the generated SQL. --- .../com/blipblipcode/query/operator/Field.kt | 12 ++++--- .../blipblipcode/query/operator/OrdenBy.kt | 34 +++++++++++++++++++ .../query/operator/SQLOperator.kt | 8 +++-- 3 files changed, 48 insertions(+), 6 deletions(-) diff --git a/query/src/main/java/com/blipblipcode/query/operator/Field.kt b/query/src/main/java/com/blipblipcode/query/operator/Field.kt index 4cd1bc8..abda93e 100644 --- a/query/src/main/java/com/blipblipcode/query/operator/Field.kt +++ b/query/src/main/java/com/blipblipcode/query/operator/Field.kt @@ -10,8 +10,12 @@ package com.blipblipcode.query.operator */ data class Field( val name: String, - val value: T?, -){ + override val value: T, +): SQLOperator{ + + override val symbol: String= "=" + override val column: String = name + override val caseConversion: CaseConversion = CaseConversion.NONE init { require(name.isNotBlank()) { "Field name cannot be blank" } @@ -21,9 +25,9 @@ data class Field( * It correctly formats the value based on its type (e.g., quoting strings). * @return A SQL assignment string. */ - fun asString(): String{ + override fun asString(): String{ val valueStr = valueString() - return "$name = $valueStr" + return "$name $symbol $valueStr" } /** diff --git a/query/src/main/java/com/blipblipcode/query/operator/OrdenBy.kt b/query/src/main/java/com/blipblipcode/query/operator/OrdenBy.kt index efc7f91..61b8e44 100644 --- a/query/src/main/java/com/blipblipcode/query/operator/OrdenBy.kt +++ b/query/src/main/java/com/blipblipcode/query/operator/OrdenBy.kt @@ -11,10 +11,17 @@ sealed interface OrderBy { is Multiple -> orders.joinToString(", ") { it.asSqlClause() } } } + fun clone(vararg params: Any?): OrderBy + data class Asc(override val column: String) : OrderBy{ override fun asString(): String { return "ORDER BY $column ASC" } + + override fun clone(vararg params: Any?): OrderBy { + return this.copy(params[0] as String) + } + override fun toString(): String { return "ORDER BY $column ASC" } @@ -23,9 +30,14 @@ sealed interface OrderBy { override fun asString(): String { return "ORDER BY $column DESC" } + override fun toString(): String { return "ORDER BY $column DESC" } + + override fun clone(vararg params: Any?): OrderBy { + return this.copy(params[0] as String) + } } data class Multiple(val orders: List) : OrderBy{ @@ -36,8 +48,30 @@ sealed interface OrderBy { return "ORDER BY ${asSqlClause()}" } + override fun clone(vararg params: Any?): OrderBy { + val newOrders = params.getOrNull(0) as? List<*> + ?: return this.copy() + + if (newOrders.all { it is OrderBy }) { + @Suppress("UNCHECKED_CAST") + return this.copy(orders = newOrders as List) + } + throw IllegalArgumentException("The parameters provided for cloning are not of the List type.") + } + override fun toString(): String { return "ORDER BY ${asSqlClause()}" } + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is Multiple) return false + return orders == other.orders + } + + override fun hashCode(): Int { + return orders.hashCode() + } } + } \ No newline at end of file diff --git a/query/src/main/java/com/blipblipcode/query/operator/SQLOperator.kt b/query/src/main/java/com/blipblipcode/query/operator/SQLOperator.kt index f0654f1..aba3ffd 100644 --- a/query/src/main/java/com/blipblipcode/query/operator/SQLOperator.kt +++ b/query/src/main/java/com/blipblipcode/query/operator/SQLOperator.kt @@ -103,7 +103,9 @@ sealed interface SQLOperator { override val caseConversion: CaseConversion = CaseConversion.NONE) : SQLOperator> { override val symbol: String = "IN" override fun toSQLString(): String { - val list = value.joinToString(", ") { caseConversion.asSqlFunction(it.toString()) } + val list = value.joinToString(", ") { + "'${caseConversion.asSqlFunction(it.toString())}'" + } return "${caseConversion.asSqlFunction(column)} $symbol ($list)" } } @@ -115,7 +117,9 @@ sealed interface SQLOperator { override val caseConversion: CaseConversion = CaseConversion.NONE) : SQLOperator> { override val symbol: String = "NOT IN" override fun toSQLString(): String { - val list = value.joinToString(", ") { caseConversion.asSqlFunction(it.toString())} + val list = value.joinToString(", ") { + "'${caseConversion.asSqlFunction(it.toString())}'" + } return "${caseConversion.asSqlFunction(column)} $symbol ($list)" } } From 8a9c3e0846de68b1b15158eef60cc967ee2d76c5 Mon Sep 17 00:00:00 2001 From: Leandro Colmenarez <89696212+LeandroLCD@users.noreply.github.com> Date: Mon, 8 Dec 2025 17:16:16 -0300 Subject: [PATCH 12/40] feat(Queryable): Add filtered `asSql` method This commit enhances the `Queryable` interface by adding a new overloaded `asSql` method. The new method accepts a predicate lambda, which allows for filtering the `SQLOperator`s that are included when generating the final SQL string. This provides more control and flexibility in SQL generation. --- query/src/main/java/com/blipblipcode/query/Queryable.kt | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/query/src/main/java/com/blipblipcode/query/Queryable.kt b/query/src/main/java/com/blipblipcode/query/Queryable.kt index 101662b..759d69b 100644 --- a/query/src/main/java/com/blipblipcode/query/Queryable.kt +++ b/query/src/main/java/com/blipblipcode/query/Queryable.kt @@ -32,4 +32,11 @@ interface Queryable { * @return The SQL query string. */ fun asSql(): String + + /** + * Returns the SQL query string representation of the object. + * @param predicate The predicate to filter the operators. + * @return The SQL query string. + */ + fun asSql(predicate: ( SQLOperator<*>) -> Boolean): String } From 8f4157989ddd11201d651701f4da596e759e4a7a Mon Sep 17 00:00:00 2001 From: Leandro Colmenarez <89696212+LeandroLCD@users.noreply.github.com> Date: Mon, 8 Dec 2025 17:16:25 -0300 Subject: [PATCH 13/40] feat(Queryable): Add predicate-based SQL generation and clear functionality This commit introduces two main enhancements to the query builder classes: a new `asSql` method that accepts a predicate for filtering SQL operators, and a `clear` method for resetting query builders. The key changes include: * **Predicate-based `asSql(predicate)` Method**: * All `Queryable` classes (`QuerySelect`, `QueryInsert`, `QueryUpdate`, `QueryDelete`, `UnionQuery`, `InnerJoint`) now implement an overloaded `asSql` method. * This new method takes a predicate lambda, allowing for the conditional inclusion of SQL operators when generating the final SQL string. * **`clear()` Method for Builders**: * `QuerySelect` and `QueryDelete` (and their respective builders) now feature a `clear()` method. * This method resets the query state by removing the `WHERE` clause and any subsequent logical operations (`AND`, `OR`), allowing builders to be reused for new queries. --- .../java/com/blipblipcode/query/InnerJoint.kt | 37 +++++++++++++++ .../com/blipblipcode/query/QueryDelete.kt | 36 +++++++++++++-- .../com/blipblipcode/query/QueryInsert.kt | 13 ++++++ .../com/blipblipcode/query/QuerySelect.kt | 45 +++++++++++++++++++ .../com/blipblipcode/query/QueryUpdate.kt | 10 +++++ .../java/com/blipblipcode/query/UnionQuery.kt | 31 +++++++++++++ 6 files changed, 169 insertions(+), 3 deletions(-) diff --git a/query/src/main/java/com/blipblipcode/query/InnerJoint.kt b/query/src/main/java/com/blipblipcode/query/InnerJoint.kt index 0739c76..948839a 100644 --- a/query/src/main/java/com/blipblipcode/query/InnerJoint.kt +++ b/query/src/main/java/com/blipblipcode/query/InnerJoint.kt @@ -59,6 +59,36 @@ class InnerJoint private constructor( } } + /** + * Generates the SQL string for the INNER JOIN statement. + * @param predicate The predicate to filter the operators. + * @return The complete INNER JOIN SQL query as a string. + * @throws IllegalArgumentException + */ + override fun asSql(predicate: (SQLOperator<*>) -> Boolean): String { + require(queries.isNotEmpty()) { "At least one query is required for an INNER JOIN" } + require(queries.size == onClauses.size) { "The number of queries must be equal to the number of ON clauses (including a placeholder for the base query)" } + val orders = queries.fold(mutableListOf()) { acc, query -> + query.getOrderBy()?.let { acc.add(it) } + query.orderBy(null) + acc + } + orders.addAll(orderBy?.let { listOf(it) } ?: emptyList()) + + val baseQuery = queries.first() + val joins = queries.drop(1).zip(onClauses.drop(1)) { query, onClause -> + "\nINNER JOIN \n${query.asSql(predicate)} \nON $onClause" + } + + return buildString { + append("${baseQuery.asSql()} ${joins.joinToString(" ")}") + if (orders.isNotEmpty()) { + appendLine() + append(OrderBy.Multiple(orders).asString()) + } + } + } + /** * Appends an ORDER BY clause to the entire UNION query. * Note that in most SQL dialects, an ORDER BY clause can only be applied to the final result of a UNION, not to individual `SELECT` statements within it. @@ -70,6 +100,13 @@ class InnerJoint private constructor( orderBy = operator return this } + /** + * Retrieves the current ORDER BY clause for the INNER JOIN query. + * @return The `OrderBy` object if set, otherwise null. + */ + fun getOrderBy(): OrderBy? { + return orderBy + } /** * A builder for creating `InnerJoint` instances. diff --git a/query/src/main/java/com/blipblipcode/query/QueryDelete.kt b/query/src/main/java/com/blipblipcode/query/QueryDelete.kt index aa51715..2cd5ff2 100644 --- a/query/src/main/java/com/blipblipcode/query/QueryDelete.kt +++ b/query/src/main/java/com/blipblipcode/query/QueryDelete.kt @@ -14,7 +14,7 @@ import com.blipblipcode.query.operator.SQLOperator * @property operations A map of logical operations (AND, OR, etc.) to be appended to the WHERE clause. */ class QueryDelete private constructor( - private var where: SQLOperator<*>, + private var where: SQLOperator<*>?, private val table: String, private val operations: LinkedHashMap, ):Queryable { @@ -42,6 +42,12 @@ class QueryDelete private constructor( return this } + fun clear() : QueryDelete { + operations.clear() + where = null + return this + } + /** * Sets or replaces the main WHERE clause of the query. * @param operator The new SQL operator for the WHERE clause. @@ -65,7 +71,8 @@ class QueryDelete private constructor( override fun getSqlOperators(): List> { return buildList { - add(where) + require(where != null) { "A WHERE clause must be specified." } + add(where!!) operations.values.forEach { add(it.operator) } } } @@ -81,9 +88,22 @@ class QueryDelete private constructor( /** * Generates the SQL string for the DELETE statement. * @return The complete DELETE SQL query as a string. + * @throws IllegalArgumentException if the WHERE clause is not set. */ override fun asSql(): String { - return "DELETE FROM $table WHERE ${where.toSQLString()} ${operations.values.joinToString(" ") { it.asString() }}".trim() + require(where != null) { "A WHERE clause must be specified." } + return "DELETE FROM $table WHERE ${where!!.toSQLString()} ${operations.values.joinToString(" ") { it.asString() }}".trim() + } + + /** + * Generates the SQL string for the DELETE statement. + * @param predicate The predicate to filter the operators. + * @return The complete DELETE SQL query as a string. + * @throws IllegalArgumentException if the WHERE clause is not set. + */ + override fun asSql(predicate: (SQLOperator<*>) -> Boolean): String { + require(where != null) { "A WHERE clause must be specified." } + return "DELETE FROM $table WHERE ${where!!.toSQLString()} ${operations.values.filter { predicate(it.operator) }.joinToString(" ") { it.asString() }}".trim() } /** @@ -95,6 +115,16 @@ class QueryDelete private constructor( ) { private var where: SQLOperator<*>? = null + /** + * Clears all conditions and resets the builder. + * @return The `QueryBuilder` instance for chaining. + */ + fun clear() : QueryBuilder { + operations.clear() + where = null + return this + } + /** * Adds an AND condition to the WHERE clause. * @param key A unique key for this condition. diff --git a/query/src/main/java/com/blipblipcode/query/QueryInsert.kt b/query/src/main/java/com/blipblipcode/query/QueryInsert.kt index a3aefdb..2d34a69 100644 --- a/query/src/main/java/com/blipblipcode/query/QueryInsert.kt +++ b/query/src/main/java/com/blipblipcode/query/QueryInsert.kt @@ -83,6 +83,19 @@ class QueryInsert private constructor( return "INSERT INTO $table ($columns) VALUES ($values)" } + /** + * Generates the SQL string for the INSERT statement. + * @param predicate The predicate to filter the operators. + * @return The complete INSERT SQL query as a string. + * @throws IllegalArgumentException if no fields are provided for insertion. + */ + override fun asSql(predicate: (SQLOperator<*>) -> Boolean): String { + require(fields.isNotEmpty()) { "At least one field must be provided for insertion." } + val columns = fields.values.filter { predicate(it) }.joinToString(", ") { it.name } + val values = fields.values.filter { predicate(it) }.joinToString(", ") { it.valueString() } + return "INSERT INTO $table ($columns) VALUES ($values)" + } + /** * A builder for creating `QueryInsert` instances. * This class provides a fluent API to construct an INSERT query. diff --git a/query/src/main/java/com/blipblipcode/query/QuerySelect.kt b/query/src/main/java/com/blipblipcode/query/QuerySelect.kt index aab6cd6..a8372a2 100644 --- a/query/src/main/java/com/blipblipcode/query/QuerySelect.kt +++ b/query/src/main/java/com/blipblipcode/query/QuerySelect.kt @@ -48,6 +48,16 @@ class QuerySelect private constructor( return this } + /** + * Clears all logical operations and the main WHERE clause from the query. + * @return The current `QuerySelect` instance for chaining. + */ + fun clear(): QuerySelect { + operations.clear() + where = null + return this + } + /** * Sets or replaces the main WHERE clause of the query. * @param operator The new SQL operator for the WHERE clause. @@ -126,6 +136,30 @@ class QuerySelect private constructor( } } } + /** + * Generates the SQL string for the SELECT statement. + * @param predicate The predicate to filter the operators. + * @return The complete SELECT SQL query as a string. + */ + override fun asSql(predicate: ( SQLOperator<*>) -> Boolean): String { + val fieldStr = if (fields.isEmpty()) "*" else fields.joinToString(", ") + val operationsStr = if (operations.isNotEmpty()) operations.values.filter { predicate(it.operator) }.joinToString(" ") { it.asString() } else "" + return buildString { + if (where == null) { + append("SELECT $fieldStr FROM $table") + }else{ + append("SELECT $fieldStr FROM $table WHERE ${where?.toSQLString()} $operationsStr".trim()) + } + if (orderBy != null) { + append(" ") + append(orderBy!!.asString()) + } + if (limit != null) { + append(" ") + append(limit!!.asString()) + } + } + } /** * Appends an ORDER BY clause to the entire UNION query. @@ -180,6 +214,16 @@ class QuerySelect private constructor( private var orderBy: OrderBy? = null private var limit: Limit? = null + /** + * Clears all logical operations and the main WHERE clause from the query. + * @return The current `QuerySelect` instance for chaining. + */ + fun clear(): QueryBuilder { + operations.clear() + where = null + return this + } + /** * Adds an AND condition to the WHERE clause. * @param key A unique key for this condition. @@ -301,6 +345,7 @@ class QuerySelect private constructor( this.orderBy = orderBy return this } + fun getOrderBy(): OrderBy? { return this.orderBy } diff --git a/query/src/main/java/com/blipblipcode/query/QueryUpdate.kt b/query/src/main/java/com/blipblipcode/query/QueryUpdate.kt index c43398d..bb38720 100644 --- a/query/src/main/java/com/blipblipcode/query/QueryUpdate.kt +++ b/query/src/main/java/com/blipblipcode/query/QueryUpdate.kt @@ -95,6 +95,16 @@ class QueryUpdate private constructor( return "UPDATE $table SET $setClause WHERE ${where.toSQLString()}".trim() } + /** + * Generates the SQL string for the UPDATE statement. + * @param predicate The predicate to filter the operators. + * @return The complete UPDATE SQL query as a string. + */ + override fun asSql(predicate: (SQLOperator<*>) -> Boolean): String { + val setClause = fields.values.filter { predicate(it) }.joinToString(", ") { it.asString() } + return "UPDATE $table SET $setClause WHERE ${where.toSQLString()}".trim() + } + /** * A builder for creating `QueryUpdate` instances. * This class provides a fluent API to construct an UPDATE query. diff --git a/query/src/main/java/com/blipblipcode/query/UnionQuery.kt b/query/src/main/java/com/blipblipcode/query/UnionQuery.kt index 57244dc..6e5bd8e 100644 --- a/query/src/main/java/com/blipblipcode/query/UnionQuery.kt +++ b/query/src/main/java/com/blipblipcode/query/UnionQuery.kt @@ -51,6 +51,30 @@ class UnionQuery private constructor( } } } + /** + * Generates the SQL string for the UNION statement. + * @param predicate The predicate to filter the operators. + * @return The complete UNION SQL query as a string. + * @throws IllegalArgumentException if less than two queries are provided. + */ + override fun asSql(predicate: (SQLOperator<*>) -> Boolean): String { + require(queries.size >= 2) { "At least two queries are required for a UNION" } + val orders = queries.fold(mutableListOf()) { acc, query -> + query.getOrderBy()?.let { acc.add(it) } + query.orderBy(null) + acc + } + orders.addAll(orderBy?.let { listOf(it) } ?: emptyList()) + val unionKeyword = if (useUnionAll) "UNION ALL" else "UNION" + + return buildString { + append(queries.joinToString("\n$unionKeyword\n") { it.asSql(predicate) }) + if (orders.isNotEmpty()) { + appendLine() + append(OrderBy.Multiple(orders).asString()) + } + } + } /** * Appends an ORDER BY clause to the entire UNION query. @@ -63,6 +87,13 @@ class UnionQuery private constructor( orderBy = operator return this } + /** + * Retrieves the current ORDER BY clause for the INNER JOIN query. + * @return The `OrderBy` object if set, otherwise null. + */ + fun getOrderBy(): OrderBy? { + return orderBy + } /** * A builder for creating `UnionQuery` instances. From 4d6156be9b1ee3d58641400dbf2d5367043868d8 Mon Sep 17 00:00:00 2001 From: Leandro Colmenarez <89696212+LeandroLCD@users.noreply.github.com> Date: Mon, 8 Dec 2025 18:34:11 -0300 Subject: [PATCH 14/40] refactor(SQLOperator): Standardize operator implementations This commit refactors `Limit`, `OrderBy`, `IsNull`, and `IsNotNull` to fully implement the `SQLOperator` interface. This standardization improves consistency across all SQL operators. Key changes include: * **`Limit` and `OrderBy` Implementation**: * `Limit` and the sealed interface `OrderBy` (including `Asc`, `Desc`, and `Multiple`) now implement `SQLOperator`. * They now define standard properties like `symbol`, `column`, `value`, and `caseConversion`. * The `asString()` methods have been updated to use these properties for SQL generation. * **`IsNull` and `IsNotNull` Adjustments**: * The generic type for `IsNull` and `IsNotNull` has been changed from `Unit` to `String?`, with the `value` property now returning `null`. * Their `toSQLString()` and `asString()` methods have been updated to support case conversion on the column. --- .../com/blipblipcode/query/operator/Limit.kt | 15 ++++--- .../blipblipcode/query/operator/OrdenBy.kt | 39 ++++++++++++------- .../query/operator/SQLOperator.kt | 16 ++++---- 3 files changed, 44 insertions(+), 26 deletions(-) diff --git a/query/src/main/java/com/blipblipcode/query/operator/Limit.kt b/query/src/main/java/com/blipblipcode/query/operator/Limit.kt index 8a19328..51359eb 100644 --- a/query/src/main/java/com/blipblipcode/query/operator/Limit.kt +++ b/query/src/main/java/com/blipblipcode/query/operator/Limit.kt @@ -6,18 +6,23 @@ package com.blipblipcode.query.operator */ data class Limit( val count: Int, - val offset: Int? = null -) { + val offset: Int? = null, +): SQLOperator{ + + override val symbol: String = "LIMIT" + override val column: String = "" + override val value: Int = count + override val caseConversion: CaseConversion = CaseConversion.NONE /** * Returns the SQL string representation of the LIMIT clause. * @return The LIMIT clause as a SQL string. */ - fun asString(): String { + override fun asString(): String { return if (offset != null && offset != 0) { - "LIMIT $count OFFSET $offset" + "$symbol $count OFFSET $offset" } else { - "LIMIT $count" + "$symbol $count" } } diff --git a/query/src/main/java/com/blipblipcode/query/operator/OrdenBy.kt b/query/src/main/java/com/blipblipcode/query/operator/OrdenBy.kt index 61b8e44..2a6eecd 100644 --- a/query/src/main/java/com/blipblipcode/query/operator/OrdenBy.kt +++ b/query/src/main/java/com/blipblipcode/query/operator/OrdenBy.kt @@ -1,48 +1,61 @@ package com.blipblipcode.query.operator -sealed interface OrderBy { - val column: String - fun asString(): String +sealed interface OrderBy:SQLOperator { + override val column: String + override val symbol: String + override val value: String + override val caseConversion: CaseConversion + override fun asString(): String fun asSqlClause(): String { return when (this) { - is Asc -> "$column ASC" - is Desc -> "$column DESC" + is Asc , is Desc -> "${caseConversion.asSqlFunction(column)} $value" is Multiple -> orders.joinToString(", ") { it.asSqlClause() } } } fun clone(vararg params: Any?): OrderBy data class Asc(override val column: String) : OrderBy{ + override val symbol: String = "ORDER BY" + override val value: String = "ASC" + override val caseConversion: CaseConversion = CaseConversion.NONE override fun asString(): String { - return "ORDER BY $column ASC" + return "$symbol ${caseConversion.asSqlFunction(column)} $value" } override fun clone(vararg params: Any?): OrderBy { - return this.copy(params[0] as String) + return this.copy(column = params[0] as String) } override fun toString(): String { - return "ORDER BY $column ASC" + return asString() } } data class Desc(override val column: String) : OrderBy{ + override val symbol: String = "ORDER BY" + override val value: String = "DESC" + override val caseConversion: CaseConversion = CaseConversion.NONE + override fun asString(): String { - return "ORDER BY $column DESC" + return "$symbol ${caseConversion.asSqlFunction(column)} $value" } - override fun toString(): String { - return "ORDER BY $column DESC" + override fun clone(vararg params: Any?): OrderBy { + return this.copy(column = params[0] as String) } - override fun clone(vararg params: Any?): OrderBy { - return this.copy(params[0] as String) + override fun toString(): String { + return asString() } } data class Multiple(val orders: List) : OrderBy{ override val column: String get() = orders.joinToString(", ") { it.column } + override val symbol: String = "ORDER BY" + override val value: String = orders.joinToString(", ") { it.value } + override val caseConversion: CaseConversion = CaseConversion.NONE + override fun asString(): String { return "ORDER BY ${asSqlClause()}" diff --git a/query/src/main/java/com/blipblipcode/query/operator/SQLOperator.kt b/query/src/main/java/com/blipblipcode/query/operator/SQLOperator.kt index aba3ffd..b4c4368 100644 --- a/query/src/main/java/com/blipblipcode/query/operator/SQLOperator.kt +++ b/query/src/main/java/com/blipblipcode/query/operator/SQLOperator.kt @@ -125,21 +125,21 @@ sealed interface SQLOperator { } /** Represents an "IS NULL" operation. */ - data class IsNull(override val column: String) : SQLOperator { + data class IsNull(override val column: String) : SQLOperator { override val symbol: String = "IS NULL" - override val value: Unit = Unit + override val value = null override val caseConversion: CaseConversion = CaseConversion.NONE - override fun toSQLString(): String = "$column $symbol" - override fun asString(): String = "$column $symbol" + override fun toSQLString(): String = "${caseConversion.asSqlFunction(column)} $symbol" + override fun asString(): String = "${caseConversion.asSqlFunction(column)} $symbol" } /** Represents an "IS NOT NULL" operation. */ - data class IsNotNull(override val column: String) : SQLOperator { + data class IsNotNull(override val column: String) : SQLOperator { override val symbol: String = "IS NOT NULL" - override val value: Unit = Unit + override val value = null override val caseConversion: CaseConversion = CaseConversion.NONE - override fun toSQLString(): String = "$column $symbol" - override fun asString(): String = "$column $symbol" + override fun toSQLString(): String = "${caseConversion.asSqlFunction(column)} $symbol" + override fun asString(): String = "${caseConversion.asSqlFunction(column)} $symbol" } /** Represents a "BETWEEN" operation. */ From e667492b320d43c8e93822b58e8656362606284c Mon Sep 17 00:00:00 2001 From: Leandro Colmenarez <89696212+LeandroLCD@users.noreply.github.com> Date: Mon, 8 Dec 2025 18:34:21 -0300 Subject: [PATCH 15/40] feat(retrofit): Add RepeatedQueryParameters for Retrofit @QueryMap This commit introduces `RepeatedQueryParameters`, a custom `LinkedHashMap` implementation designed to handle repeated query parameters in Retrofit. This utility class allows lists provided as values in a `@QueryMap` to be automatically expanded into multiple query parameters with the same key. This is useful for API endpoints that expect repeated keys, such as `?key=value1&key=value2`. Key features: - **`RepeatedQueryParameters` class**: Extends `LinkedHashMap` and overrides the `entries` property to transform list values into multiple `Map.Entry` objects. - **Factory methods**: Provides `create()`, `fromMap()`, and `empty()` for convenient instantiation. - **Handles lists**: Automatically expands `List<*>` values into separate key-value pairs. - **Null safety**: Throws exceptions for null keys and skips null values within lists. --- .../query/retrofit/RepeatedQueryParameters.kt | 138 ++++++++++++++++++ 1 file changed, 138 insertions(+) create mode 100644 query/src/main/java/com/blipblipcode/query/retrofit/RepeatedQueryParameters.kt diff --git a/query/src/main/java/com/blipblipcode/query/retrofit/RepeatedQueryParameters.kt b/query/src/main/java/com/blipblipcode/query/retrofit/RepeatedQueryParameters.kt new file mode 100644 index 0000000..e7f0ddf --- /dev/null +++ b/query/src/main/java/com/blipblipcode/query/retrofit/RepeatedQueryParameters.kt @@ -0,0 +1,138 @@ +package com.blipblipcode.query.retrofit + + +/** + * A custom HashMap implementation that handles repeated query parameters for Retrofit. + * + * This class allows you to pass lists as values in @QueryMap, and they will be + * automatically expanded into multiple query parameters with the same key. + * + * Example: + * ``` + * val params = RepeatedQueryParameters.create( + * "terminal" to 2, + * "tire_status" to listOf(3, 4), + * "category" to "active" + * ) + * // Results in: ?terminal=2&tire_status=3&tire_status=4&category=active + * ``` + */ +class RepeatedQueryParameters private constructor( + m: MutableMap +) : LinkedHashMap(m) { + + companion object { + /** + * Creates a RepeatedQueryParameters instance with the given key-value pairs. + * + * @param pairs Variable number of key-value pairs where values can be: + * - Single values (String, Int, Boolean, etc.) + * - Lists (will be expanded into multiple parameters) + */ + fun create(vararg pairs: Pair): RepeatedQueryParameters { + return RepeatedQueryParameters(linkedMapOf(*pairs)) + } + + /** + * Creates a RepeatedQueryParameters instance from an existing map. + */ + fun fromMap(map: MutableMap): RepeatedQueryParameters { + return RepeatedQueryParameters(LinkedHashMap(map)) + } + + /** + * Creates an empty RepeatedQueryParameters instance. + */ + fun empty(): RepeatedQueryParameters { + return RepeatedQueryParameters(LinkedHashMap()) + } + } + + /** + * Adds a single parameter to the query map. + */ + fun addParameter(key: String, value: Any): RepeatedQueryParameters { + this[key] = value + return this + } + + /** + * Adds multiple values for the same parameter key. + */ + fun addRepeatedParameter(key: String, values: List<*>): RepeatedQueryParameters { + this[key] = values + return this + } + + /** + * Overrides the entries property to handle list values by expanding them + * into multiple entries with the same key. + */ + override val entries: MutableSet> + get() { + val originSet: Set> = super.entries + val newSet: MutableSet> = HashSet() + + for ((key, entryValue) in originSet) { + val entryKey = key ?: throw IllegalArgumentException( + "Query map contained null key." + ) + + // Skip null values + requireNotNull(entryValue) { + "Query map contained null value for key '$entryKey'." + } + + when (entryValue) { + is List<*> -> { + // Expand list into multiple entries with the same key + for (arrayValue in entryValue) { + if (arrayValue != null) { // Skip null values in list + val newEntry: MutableMap.MutableEntry = + SimpleEntry(entryKey, arrayValue) + newSet.add(newEntry) + } + } + } + else -> { + // Single value entry + val newEntry: MutableMap.MutableEntry = + SimpleEntry(entryKey, entryValue) + newSet.add(newEntry) + } + } + } + + return newSet + } + + /** + * Simple implementation of Map.Entry for creating new entries. + */ + private class SimpleEntry( + private val keySimple: K, + private var valueSimple: V + ) : MutableMap.MutableEntry { + + override val key: K get() = keySimple + override val value: V get() = valueSimple + + override fun setValue(newValue: V): V { + val oldValue = valueSimple + valueSimple = newValue + return oldValue + } + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is Map.Entry<*, *>) return false + return key == other.key && value == other.value + } + + override fun hashCode(): Int { + return (key?.hashCode() ?: 0) xor (value?.hashCode() ?: 0) + } + + override fun toString(): String = "$key=$value" + } +} \ No newline at end of file From d797d6d9228c3b780b4591b043abb99f20216793 Mon Sep 17 00:00:00 2001 From: Leandro Colmenarez <89696212+LeandroLCD@users.noreply.github.com> Date: Mon, 8 Dec 2025 18:34:56 -0300 Subject: [PATCH 16/40] feat(Queryable): Add `asQueryRepeatedQueryParameters` extension This commit introduces a new extension function, `asQueryRepeatedQueryParameters`, for the `Queryable` interface. This function converts the SQL operators of a query into a `RepeatedQueryParameters` object, which is useful for integrations like Retrofit. Key features include: - A `predicate` parameter to conditionally filter which query parameters are included. - Support for regular parameters and repeated parameters (from `List` values). - Null-valued parameters are automatically excluded. --- .../blipblipcode/query/utils/Extensions.kt | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/query/src/main/java/com/blipblipcode/query/utils/Extensions.kt b/query/src/main/java/com/blipblipcode/query/utils/Extensions.kt index bbe005d..f01b7b7 100644 --- a/query/src/main/java/com/blipblipcode/query/utils/Extensions.kt +++ b/query/src/main/java/com/blipblipcode/query/utils/Extensions.kt @@ -6,6 +6,7 @@ import com.blipblipcode.query.InnerJoint import com.blipblipcode.query.QuerySelect import com.blipblipcode.query.Queryable import com.blipblipcode.query.UnionQuery +import com.blipblipcode.query.retrofit.RepeatedQueryParameters /** * Creates an `InnerJoint` by joining this `QuerySelect` with another one. @@ -86,3 +87,22 @@ fun UnionQuery.addQuery(query: QuerySelect): UnionQuery { .apply { if (this@addQuery.useUnionAll) unionAll() else union() } .build() } + +fun Queryable.asQueryRepeatedQueryParameters(predicate:(Pair) -> Boolean = {true}):RepeatedQueryParameters{ + return getSqlOperators() + .map { it.toPair() } + .fold(RepeatedQueryParameters.empty()) { acc, filter -> + when { + predicate.invoke(filter) && filter.second != null ->{ + if(filter.second is List<*>){ + acc.addRepeatedParameter(filter.first, filter.second as List<*>) + }else{ + acc.addParameter(filter.first, filter.second!!) + } + acc + } + + else -> acc + } + } +} From a5d00b2724955c1b92a61228fcc0edf33eac1ab5 Mon Sep 17 00:00:00 2001 From: Leandro Colmenarez <89696212+LeandroLCD@users.noreply.github.com> Date: Mon, 8 Dec 2025 18:35:23 -0300 Subject: [PATCH 17/40] feat(QuerySelect): Include orderBy and limit in getSqlOperators This commit enhances the `getSqlOperators()` method in `QuerySelect`. The `orderBy` and `limit` operators are now included in the returned list of SQL operators. This improves query introspection by providing a more complete representation of the query's components. --- query/src/main/java/com/blipblipcode/query/QuerySelect.kt | 2 ++ 1 file changed, 2 insertions(+) diff --git a/query/src/main/java/com/blipblipcode/query/QuerySelect.kt b/query/src/main/java/com/blipblipcode/query/QuerySelect.kt index a8372a2..81e916d 100644 --- a/query/src/main/java/com/blipblipcode/query/QuerySelect.kt +++ b/query/src/main/java/com/blipblipcode/query/QuerySelect.kt @@ -102,6 +102,8 @@ class QuerySelect private constructor( return buildList { where?.let { add(it) } operations.values.forEach { add(it.operator) } + orderBy?.let { add(it) } + limit?.let { add(it) } } } From ed4e44cf0e8d947982bf32501a001cab9e1cf8e8 Mon Sep 17 00:00:00 2001 From: Leandro Colmenarez <89696212+LeandroLCD@users.noreply.github.com> Date: Mon, 8 Dec 2025 18:35:38 -0300 Subject: [PATCH 18/40] feat(Retrofit): Add `RepeatedQueryParameters` for Retrofit integration This commit introduces `RepeatedQueryParameters`, a utility class designed to bridge the gap between the `Query` library and Retrofit's `@QueryMap` annotation. It also adds a convenient extension function to convert `Queryable` objects directly into this new format. The key changes include: * **New `RepeatedQueryParameters` class**: * This class extends `LinkedHashMap` and is designed to handle both single and repeated query parameters, which are common in REST APIs. * It provides `addParameter()` for single values and `addRepeatedParameter()` for lists. * A custom `entries` property expands list values into multiple `Map.Entry` objects, making it compatible with Retrofit's `@QueryMap` when used for repeated parameters (e.g., `?key=val1&key=val2`). * **New `asQueryRepeatedQueryParameters()` extension function**: * This function converts any `Queryable` object (like `QuerySelect`) into a `RepeatedQueryParameters` instance. * It intelligently handles `SQLOperator` values, mapping single values directly and converting list-based operators (like `IN`) into repeated parameters. * Operators with `Unit` or `null` values (e.g., `IsNull`) are automatically filtered out. * An optional predicate allows for custom filtering of which operators are included. * **Comprehensive Unit Tests**: * Added `RepeatedQueryParametersTest.kt` to thoroughly validate the new class's behavior, including adding, overwriting, and expanding parameters. * Added `QueryableRepeatedQueryParametersExtensionTest.kt` to test the conversion from `Queryable` to `RepeatedQueryParameters`, covering simple operators, list-based operators, and filtering logic. --- ...bleRepeatedQueryParametersExtensionTest.kt | 139 +++++++ .../retrofit/RepeatedQueryParametersTest.kt | 345 ++++++++++++++++++ 2 files changed, 484 insertions(+) create mode 100644 query/src/test/java/com/blipblipcode/query/retrofit/QueryableRepeatedQueryParametersExtensionTest.kt create mode 100644 query/src/test/java/com/blipblipcode/query/retrofit/RepeatedQueryParametersTest.kt diff --git a/query/src/test/java/com/blipblipcode/query/retrofit/QueryableRepeatedQueryParametersExtensionTest.kt b/query/src/test/java/com/blipblipcode/query/retrofit/QueryableRepeatedQueryParametersExtensionTest.kt new file mode 100644 index 0000000..028962c --- /dev/null +++ b/query/src/test/java/com/blipblipcode/query/retrofit/QueryableRepeatedQueryParametersExtensionTest.kt @@ -0,0 +1,139 @@ +package com.blipblipcode.query.retrofit + +import com.blipblipcode.query.QuerySelect +import com.blipblipcode.query.operator.SQLOperator +import com.blipblipcode.query.utils.asQueryRepeatedQueryParameters +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class QueryableRepeatedQueryParametersExtensionTest { + + @Test + fun `asQueryRepeatedQueryParameters with simple operators`() { + // Given: A QuerySelect with simple SQL operators + val query = QuerySelect.builder("users") + .where(SQLOperator.Equals("name", "John")) + .and("age", SQLOperator.GreaterThan("age", 25)) + .and("status", SQLOperator.Equals("status", "active")) + .build() + + // When: Converting to RepeatedQueryParameters + val params = query.asQueryRepeatedQueryParameters() + + // Then: All operators should be converted to parameters + assertEquals("John", params["name"]) + assertEquals(25, params["age"]) + assertEquals("active", params["status"]) + assertEquals(3, params.size) + } + + @Test + fun `asQueryRepeatedQueryParameters with list operators`() { + // Given: A QuerySelect with operators that contain lists + val query = QuerySelect.builder("products") + .where(SQLOperator.In("category", listOf("Electronics", "Computers", "Phones"))) + .and("brand", SQLOperator.Equals("brand", "Apple")) + .build() + + // When: Converting to RepeatedQueryParameters + val params = query.asQueryRepeatedQueryParameters() + + // Then: Lists should be expanded as repeated parameters + val categoryList = params["category"] as List<*> + assertEquals(listOf("Electronics", "Computers", "Phones"), categoryList) + assertEquals("Apple", params["brand"]) + assertEquals(2, params.size) + } + + @Test + fun `asQueryRepeatedQueryParameters with predicate filter`() { + // Given: A QuerySelect with multiple operators + val query = QuerySelect.builder("orders") + .where(SQLOperator.Equals("customer_id", 123)) + .and("status", SQLOperator.In("status", listOf("pending", "processing"))) + .and("total", SQLOperator.GreaterThan("total", 100.0)) + .build() + + // When: Converting to RepeatedQueryParameters with a predicate that excludes "total" + val params = query.asQueryRepeatedQueryParameters { (key, _) -> key != "total" } + + // Then: Only filtered parameters should be included + assertEquals(123, params["customer_id"]) + val statusList = params["status"] as List<*> + assertEquals(listOf("pending", "processing"), statusList) + assertTrue(!params.containsKey("total")) + assertEquals(2, params.size) + } + + @Test + fun `asQueryRepeatedQueryParameters with empty query`() { + // Given: An empty QuerySelect with no operators + val query = QuerySelect.builder("users").build() + + // When: Converting to RepeatedQueryParameters + val params = query.asQueryRepeatedQueryParameters() + + // Then: An empty RepeatedQueryParameters should be returned + assertTrue(params.isEmpty()) + } + + @Test + fun `asQueryRepeatedQueryParameters ignores null values`() { + // Given: A QuerySelect with operators that have null values (like IsNull with Unit) + val query = QuerySelect.builder("users") + .where(SQLOperator.Equals("name", "John")) + .and("description", SQLOperator.IsNull("description")) + .build() + + // When: Converting to RepeatedQueryParameters + val params = query.asQueryRepeatedQueryParameters() + + // Then: Only non-null values should be included, null values should be ignored + assertEquals("John", params["name"]) + // IsNull operator with Unit/null value should be completely ignored + assertTrue("description key should not be present", !params.containsKey("description")) + assertEquals(1, params.size) + } + + @Test + fun `asQueryRepeatedQueryParameters with mixed null and non-null values`() { + // Given: A QuerySelect with mix of null and non-null operators + val query = QuerySelect.builder("products") + .where(SQLOperator.Equals("name", "Product1")) + .and("category", SQLOperator.In("category", listOf("A", "B"))) + .and("description", SQLOperator.IsNull("description")) + .and("price", SQLOperator.GreaterThan("price", 100)) + .build() + + // When: Converting to RepeatedQueryParameters + val params = query.asQueryRepeatedQueryParameters() + + // Then: Only non-null values should be included + assertEquals("Product1", params["name"]) + assertEquals(listOf("A", "B"), params["category"]) + assertEquals(100, params["price"]) + assertTrue("description key should not be present", !params.containsKey("description")) + assertEquals(3, params.size) + } + + @Test + fun `asQueryRepeatedQueryParameters entries expansion`() { + // Given: A QuerySelect with mixed single values and lists + val query = QuerySelect.builder("products") + .where(SQLOperator.Equals("brand", "Samsung")) + .and("colors", SQLOperator.In("colors", listOf("red", "blue", "green"))) + .build() + + // When: Converting to RepeatedQueryParameters and getting entries + val params = query.asQueryRepeatedQueryParameters() + val entries = params.entries + + // Then: Lists should be expanded in entries while single values remain single + assertEquals(4, entries.size) // 1 for brand + 3 for colors + assertTrue(entries.any { it.key == "brand" && it.value == "Samsung" }) + assertTrue(entries.any { it.key == "colors" && it.value == "red" }) + assertTrue(entries.any { it.key == "colors" && it.value == "blue" }) + assertTrue(entries.any { it.key == "colors" && it.value == "green" }) + } +} diff --git a/query/src/test/java/com/blipblipcode/query/retrofit/RepeatedQueryParametersTest.kt b/query/src/test/java/com/blipblipcode/query/retrofit/RepeatedQueryParametersTest.kt new file mode 100644 index 0000000..74a0eb8 --- /dev/null +++ b/query/src/test/java/com/blipblipcode/query/retrofit/RepeatedQueryParametersTest.kt @@ -0,0 +1,345 @@ +package com.blipblipcode.query.retrofit + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertThrows +import org.junit.Assert.assertTrue +import org.junit.Test + +class RepeatedQueryParametersTest { + + @Test + fun `addParameter with a new key`() { + // Given: An empty RepeatedQueryParameters instance + val params = RepeatedQueryParameters.empty() + + // When: Adding a new key-value pair + params.addParameter("name", "John") + + // Then: The map should contain that exact pair + assertEquals("John", params["name"]) + assertEquals(1, params.size) + } + + @Test + fun `addParameter overwriting an existing key`() { + // Given: A RepeatedQueryParameters with an existing key + val params = RepeatedQueryParameters.create("name" to "John") + + // When: Adding the same key with a different value + params.addParameter("name", "Jane") + + // Then: The previous value should be overwritten + assertEquals("Jane", params["name"]) + assertEquals(1, params.size) + } + + @Test + fun `addParameter with various value types`() { + // Given: An empty RepeatedQueryParameters instance + val params = RepeatedQueryParameters.empty() + + // When: Adding different value types + params.addParameter("name", "John") + params.addParameter("age", 30) + params.addParameter("height", 1.80) + params.addParameter("active", true) + + // Then: All types should be stored correctly + assertEquals("John", params["name"]) + assertEquals(30, params["age"]) + assertEquals(1.80, params["height"]) + assertEquals(true, params["active"]) + assertEquals(4, params.size) + } + + @Test + fun `addParameter with an empty key`() { + // Given: An empty RepeatedQueryParameters instance + val params = RepeatedQueryParameters.empty() + + // When: Adding a parameter with an empty key + params.addParameter("", "value") + + // Then: The empty key should be handled correctly + assertEquals("value", params[""]) + assertEquals(1, params.size) + } + + @Test + fun `addParameter with an empty value`() { + // Given: An empty RepeatedQueryParameters instance + val params = RepeatedQueryParameters.empty() + + // When: Adding a parameter with an empty value + params.addParameter("key", "") + + // Then: The empty value should be stored correctly + assertEquals("", params["key"]) + assertEquals(1, params.size) + } + + @Test + fun `addParameter returns the same instance`() { + // Given: A RepeatedQueryParameters instance + val params = RepeatedQueryParameters.empty() + + // When: Adding a parameter + val result = params.addParameter("name", "John") + + // Then: The same instance should be returned for method chaining + assertEquals(params, result) + } + + @Test + fun `addRepeatedParameter with a new key`() { + // Given: An empty RepeatedQueryParameters instance + val params = RepeatedQueryParameters.empty() + val values = listOf("tag1", "tag2", "tag3") + + // When: Adding a list of values for a new key + params.addRepeatedParameter("tags", values) + + // Then: The list should be stored correctly + assertEquals(values, params["tags"]) + assertEquals(1, params.size) + } + + @Test + fun `addRepeatedParameter overwriting an existing key`() { + // Given: A RepeatedQueryParameters with an existing key-value pair + val params = RepeatedQueryParameters.create("tags" to "single_tag") + val newValues = listOf("tag1", "tag2") + + // When: Adding a list for the same key + params.addRepeatedParameter("tags", newValues) + + // Then: The previous value should be overwritten with the new list + assertEquals(newValues, params["tags"]) + assertEquals(1, params.size) + } + + @Test + fun `addRepeatedParameter with an empty list`() { + // Given: An empty RepeatedQueryParameters instance + val params = RepeatedQueryParameters.empty() + val emptyList = emptyList() + + // When: Adding an empty list for a key + params.addRepeatedParameter("tags", emptyList) + + // Then: The empty list should be stored correctly + assertEquals(emptyList, params["tags"]) + assertEquals(1, params.size) + } + + @Test + fun `addRepeatedParameter with a list of mixed types`() { + // Given: An empty RepeatedQueryParameters instance + val params = RepeatedQueryParameters.empty() + val mixedList = listOf("string", 42, true, 3.14) + + // When: Adding a list with mixed data types + params.addRepeatedParameter("mixed", mixedList) + + // Then: The mixed list should be stored correctly + assertEquals(mixedList, params["mixed"]) + assertEquals(1, params.size) + } + + @Test + fun `addRepeatedParameter with a list containing null values`() { + // Given: An empty RepeatedQueryParameters instance + val params = RepeatedQueryParameters.empty() + val listWithNulls = listOf("value1", null, "value2", null) + + // When: Adding a list containing null elements + params.addRepeatedParameter("tags", listWithNulls) + + // Then: The list with nulls should be stored correctly in the map + assertEquals(listWithNulls, params["tags"]) + assertEquals(1, params.size) + } + + @Test + fun `addRepeatedParameter with an empty key`() { + // Given: An empty RepeatedQueryParameters instance + val params = RepeatedQueryParameters.empty() + val values = listOf("value1", "value2") + + // When: Adding a repeated parameter with an empty key + params.addRepeatedParameter("", values) + + // Then: The empty key should be handled correctly + assertEquals(values, params[""]) + assertEquals(1, params.size) + } + + @Test + fun `addRepeatedParameter returns the same instance`() { + // Given: A RepeatedQueryParameters instance + val params = RepeatedQueryParameters.empty() + val values = listOf("value1", "value2") + + // When: Adding a repeated parameter + val result = params.addRepeatedParameter("tags", values) + + // Then: The same instance should be returned for method chaining + assertEquals(params, result) + } + + @Test + fun `getEntries on an empty map`() { + // Given: An empty RepeatedQueryParameters instance + val params = RepeatedQueryParameters.empty() + + // When: Getting the entries + val entries = params.entries + + // Then: An empty set should be returned + assertTrue(entries.isEmpty()) + } + + @Test + fun `getEntries with single value parameters`() { + // Given: A RepeatedQueryParameters with single-value entries + val params = RepeatedQueryParameters.create( + "name" to "John", + "age" to 30, + "active" to true + ) + + // When: Getting the entries + val entries = params.entries + + // Then: The entries should match the original map + assertEquals(3, entries.size) + assertTrue(entries.any { it.key == "name" && it.value == "John" }) + assertTrue(entries.any { it.key == "age" && it.value == 30 }) + assertTrue(entries.any { it.key == "active" && it.value == true }) + } + + @Test + fun `getEntries with a repeated parameter`() { + // Given: A RepeatedQueryParameters with a list value + val params = RepeatedQueryParameters.create("tags" to listOf("tag1", "tag2", "tag3")) + + // When: Getting the entries + val entries = params.entries + + // Then: Multiple entries with the same key should be created + assertEquals(3, entries.size) + assertTrue(entries.any { it.key == "tags" && it.value == "tag1" }) + assertTrue(entries.any { it.key == "tags" && it.value == "tag2" }) + assertTrue(entries.any { it.key == "tags" && it.value == "tag3" }) + } + + @Test + fun `getEntries with a mix of single and repeated parameters`() { + // Given: A RepeatedQueryParameters with both single values and lists + val params = RepeatedQueryParameters.create( + "name" to "John", + "tags" to listOf("tag1", "tag2"), + "active" to true + ) + + // When: Getting the entries + val entries = params.entries + + // Then: Single values should remain as single entries, lists should be expanded + assertEquals(4, entries.size) + assertTrue(entries.any { it.key == "name" && it.value == "John" }) + assertTrue(entries.any { it.key == "tags" && it.value == "tag1" }) + assertTrue(entries.any { it.key == "tags" && it.value == "tag2" }) + assertTrue(entries.any { it.key == "active" && it.value == true }) + } + + @Test + fun `getEntries with a repeated parameter containing nulls`() { + // Given: A RepeatedQueryParameters with a list containing nulls + val params = RepeatedQueryParameters.empty() + params.addRepeatedParameter("tags", listOf("tag1", null, "tag2", null)) + + // When: Getting the entries + val entries = params.entries + + // Then: Null values should be skipped + assertEquals(2, entries.size) + assertTrue(entries.any { it.key == "tags" && it.value == "tag1" }) + assertTrue(entries.any { it.key == "tags" && it.value == "tag2" }) + } + + @Test + fun `getEntries with an empty list value`() { + // Given: A RepeatedQueryParameters with an empty list + val params = RepeatedQueryParameters.create("tags" to emptyList()) + + // When: Getting the entries + val entries = params.entries + + // Then: No entries should be created for the empty list + assertTrue(entries.isEmpty()) + } + + @Test + fun `getEntries throws exception for a null value`() { + // Given: A RepeatedQueryParameters with a null value (using reflection to force it) + val params = RepeatedQueryParameters.empty() + @Suppress("UNCHECKED_CAST") + val map = params as MutableMap + map["key"] = null + + // When/Then: Getting entries should throw IllegalArgumentException + assertThrows(IllegalArgumentException::class.java) { + params.entries + } + } + + @Test + fun `getEntries with special character keys`() { + // Given: A RepeatedQueryParameters with special character keys + val params = RepeatedQueryParameters.create( + "key&with&ersand" to "value1", + "key=with=equals" to "value2", + "key?with?question" to "value3" + ) + + // When: Getting the entries + val entries = params.entries + + // Then: Special character keys should be handled correctly + assertEquals(3, entries.size) + assertTrue(entries.any { it.key == "key&with&ersand" && it.value == "value1" }) + assertTrue(entries.any { it.key == "key=with=equals" && it.value == "value2" }) + assertTrue(entries.any { it.key == "key?with?question" && it.value == "value3" }) + } + + @Test + fun `getEntries return set is mutable`() { + // Given: A RepeatedQueryParameters with some entries + val params = RepeatedQueryParameters.create("key" to "value") + + // When: Getting the entries + val entries = params.entries + + // Then: The returned set should contain the expected entry and be of correct size + assertEquals(1, entries.size) + assertTrue(entries.any { it.key == "key" && it.value == "value" }) + } + + @Test + fun `getEntries entry values are mutable`() { + // Given: A RepeatedQueryParameters with an entry + val params = RepeatedQueryParameters.create("key" to "original") + + // When: Getting an entry and changing its value + val entries = params.entries + val entry = entries.first() + val oldValue = entry.setValue("modified") + + // Then: The entry value should be changed, but original map should remain unchanged + assertEquals("original", oldValue) + assertEquals("modified", entry.value) + assertEquals("original", params["key"]) // Original map should not be affected + } + +} \ No newline at end of file From c1cd2e1dda7d4d26768be71c6abfd19be56955f0 Mon Sep 17 00:00:00 2001 From: Leandro Colmenarez <89696212+LeandroLCD@users.noreply.github.com> Date: Mon, 8 Dec 2025 18:35:42 -0300 Subject: [PATCH 19/40] feat(Retrofit): Add `RepeatedQueryParameters` for Retrofit integration This commit introduces `RepeatedQueryParameters`, a utility class designed to bridge the gap between the `Query` library and Retrofit's `@QueryMap` annotation. It also adds a convenient extension function to convert `Queryable` objects directly into this new format. The key changes include: * **New `RepeatedQueryParameters` class**: * This class extends `LinkedHashMap` and is designed to handle both single and repeated query parameters, which are common in REST APIs. * It provides `addParameter()` for single values and `addRepeatedParameter()` for lists. * A custom `entries` property expands list values into multiple `Map.Entry` objects, making it compatible with Retrofit's `@QueryMap` when used for repeated parameters (e.g., `?key=val1&key=val2`). * **New `asQueryRepeatedQueryParameters()` extension function**: * This function converts any `Queryable` object (like `QuerySelect`) into a `RepeatedQueryParameters` instance. * It intelligently handles `SQLOperator` values, mapping single values directly and converting list-based operators (like `IN`) into repeated parameters. * Operators with `Unit` or `null` values (e.g., `IsNull`) are automatically filtered out. * An optional predicate allows for custom filtering of which operators are included. * **Comprehensive Unit Tests**: * Added `RepeatedQueryParametersTest.kt` to thoroughly validate the new class's behavior, including adding, overwriting, and expanding parameters. * Added `QueryableRepeatedQueryParametersExtensionTest.kt` to test the conversion from `Queryable` to `RepeatedQueryParameters`, covering simple operators, list-based operators, and filtering logic. --- README.md | 63 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 63 insertions(+) diff --git a/README.md b/README.md index 6c7d1fc..7a7f292 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,7 @@ Diseñada para integrarse perfectamente con la base de datos de Android y Room a [Instalación](#-instalación) • [Uso](#-uso) • [Integración con Room](#-integración-con-room) • +[RepeatedQueryParameters](#-repeatedqueryparameters) • [Contribuir](#-contribuir) @@ -34,6 +35,7 @@ Diseñada para integrarse perfectamente con la base de datos de Android y Room a - [DELETE](#-delete) - [INNER JOIN](#-inner-join) - [🔗 Integración con Room](#-integración-con-room) +- [📡 RepeatedQueryParameters](#-repeatedqueryparameters) - [📝 Ejemplos Avanzados](#-ejemplos-avanzados) - [🤝 Contribuir](#-contribuir) @@ -209,6 +211,67 @@ class UserRepository(private val userDao: UserDao) { --- +## 📡 RepeatedQueryParameters + +`RepeatedQueryParameters` es una clase utilitaria destinada a facilitar el uso de parámetros de consulta repetidos cuando se integra con Retrofit y otras librerías que consumen `Map`/`QueryMap` de parámetros. + +Descripción breve: +- Permite pasar listas como valores en un `@QueryMap` y que Retrofit las expanda como múltiples pares clave=valor en la URL (ej: `?tag=a&tag=b`). +- Mantiene el orden de inserción (hereda de `LinkedHashMap`) para reproducibilidad en pruebas y cachés. +- Omite elementos `null` dentro de listas y lanza excepción si se intenta usar una clave o valor `null`. + +API y métodos principales: +- `RepeatedQueryParameters.create(vararg pairs: Pair): RepeatedQueryParameters` — Crea la instancia a partir de pares clave/valor. +- `RepeatedQueryParameters.fromMap(map: MutableMap): RepeatedQueryParameters` — Convierte un `Map` existente. +- `RepeatedQueryParameters.empty(): RepeatedQueryParameters` — Instancia vacía. +- `addParameter(key: String, value: Any)` — Agrega o reemplaza un parámetro simple. +- `addRepeatedParameter(key: String, values: List<*>)` — Agrega una lista que será expandida. + +Compatibilidad con la extensión `Queryable.asQueryRepeatedQueryParameters`: + +La librería expone una extensión `Queryable.asQueryRepeatedQueryParameters()` que convierte los operadores SQL (devueltos por `getSqlOperators()`) en una instancia de `RepeatedQueryParameters`. Esta extensión: +- Filtra los operadores usando un `predicate: (Pair) -> Boolean` opcional. +- Expande automáticamente listas en `RepeatedQueryParameters` mediante `addRepeatedParameter`. + +Ejemplo usando la extensión `asQueryRepeatedQueryParameters`: + +```kotlin +// Supongamos que `query` es un QuerySelect u otro Queryable con operadores que incluyen listas +val params = query.asQueryRepeatedQueryParameters() +// ahora `params` puede ser pasado directamente a Retrofit como @QueryMap +``` + +Ejemplo de uso con Retrofit: + +```kotlin +interface ProductApi { + @GET("api/v2/product/list") + suspend fun getProductList( + @QueryMap options: RepeatedQueryParameters + ): ResponsePaginListDto +} + +// Construcción de parámetros +val options = RepeatedQueryParameters.create( + "limit" to 50, + "offset" to 0, + "status" to listOf("active", "pending"), + "brand" to "michelin", + "sort" to "date" +) + +// Llamada al API +val response = api.getProductList(options = options) +// Resultado en URL: ?limit=50&offset=0&status=active&status=pending&brand=michelin&sort=date +``` + +Notas de uso y buenas prácticas: +- Evita pasar valores `null` como clave o valor (la clase lanza excepción). +- Para agregar dinámicamente parámetros desde un `Queryable`, usa `asQueryRepeatedQueryParameters()` y opcionalmente provee un `predicate` para incluir/excluir pares. +- Mantén simples los valores no list (String, Int, Boolean); las listas son las que generan repetición en la URL. + +--- + ## 📝 Ejemplos Avanzados ### Consultas Complejas con Múltiples Condiciones From 97bbc8bacf357e9b7aec6d0ce73df6bdd4a26073 Mon Sep 17 00:00:00 2001 From: Leandro Colmenarez <89696212+LeandroLCD@users.noreply.github.com> Date: Mon, 8 Dec 2025 21:58:59 -0300 Subject: [PATCH 20/40] docs(QuerySelect): Improve KDoc for getOrderBy function This commit enhances the KDoc for the `getOrderBy()` function in the `QuerySelect` builder. The new documentation clarifies that the function returns the `OrderBy` object for the query, or `null` if an `ORDER BY` clause has not been set. --- query/src/main/java/com/blipblipcode/query/QuerySelect.kt | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/query/src/main/java/com/blipblipcode/query/QuerySelect.kt b/query/src/main/java/com/blipblipcode/query/QuerySelect.kt index 2a4d6b7..59c98ee 100644 --- a/query/src/main/java/com/blipblipcode/query/QuerySelect.kt +++ b/query/src/main/java/com/blipblipcode/query/QuerySelect.kt @@ -347,10 +347,10 @@ class QuerySelect private constructor( this.orderBy = orderBy return this } - fun getOrderBy(): OrderBy? { - return this.orderBy - } - + /** + * Returns the ORDER BY clause for the query. + * @return The OrderBy object, or null if not set. + */ fun getOrderBy(): OrderBy? { return this.orderBy } From 27c6476a0004a25b08cde074c4f81d1696efcf68 Mon Sep 17 00:00:00 2001 From: Leandro Colmenarez <89696212+LeandroLCD@users.noreply.github.com> Date: Mon, 8 Dec 2025 22:32:34 -0300 Subject: [PATCH 21/40] Add Retrofit badge and update README links --- README.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 7a7f292..e775dd5 100644 --- a/README.md +++ b/README.md @@ -7,6 +7,7 @@ [![Kotlin](https://img.shields.io/badge/Kotlin-2.2.21+-purple.svg?style=flat&logo=kotlin)](https://kotlinlang.org) [![Android](https://img.shields.io/badge/Android-API%2021+-green.svg?style=flat&logo=android)](https://developer.android.com) [![Room](https://img.shields.io/badge/Room-Compatible-blue.svg?style=flat)](https://developer.android.com/training/data-storage/room) +[![Retrofit](https://img.shields.io/badge/Retrofit-Compatible-red.svg?style=flat)]([https://developer.android.com/training/data-storage/room](https://square.github.io/retrofit/)) ![GitHub release (latest by date)](https://img.shields.io/github/v/release/LeandroLCD/android-sql-query) [![Run Query Unit Tests](https://github.com/LeandroLCD/android-sql-query/actions/workflows/run-query-tests.yml/badge.svg)](https://github.com/LeandroLCD/android-sql-query/actions/workflows/run-query-tests.yml) @@ -35,7 +36,7 @@ Diseñada para integrarse perfectamente con la base de datos de Android y Room a - [DELETE](#-delete) - [INNER JOIN](#-inner-join) - [🔗 Integración con Room](#-integración-con-room) -- [📡 RepeatedQueryParameters](#-repeatedqueryparameters) +- [📡 Integración con retrofit -> RepeatedQueryParameters](#-repeatedqueryparameters) - [📝 Ejemplos Avanzados](#-ejemplos-avanzados) - [🤝 Contribuir](#-contribuir) From c7f40a612e28512af9bb44b00ce5fa7532501784 Mon Sep 17 00:00:00 2001 From: Leandro Colmenarez <89696212+LeandroLCD@users.noreply.github.com> Date: Wed, 10 Dec 2025 18:05:15 -0300 Subject: [PATCH 22/40] fix(SQLOperator): Correctly quote values in `Between` operator This commit fixes a bug in the `Between` SQL operator where the start and end values were not being properly quoted in the generated SQL string. This could lead to SQL syntax errors when using string values. The `toSQLString()` and `asString()` methods in `SQLOperator.Between` have been updated to enclose the start and end values in single quotes. --- .../java/com/blipblipcode/query/UnionQuery.kt | 19 +++++++++++++++++++ .../query/operator/SQLOperator.kt | 4 ++-- 2 files changed, 21 insertions(+), 2 deletions(-) diff --git a/query/src/main/java/com/blipblipcode/query/UnionQuery.kt b/query/src/main/java/com/blipblipcode/query/UnionQuery.kt index 6e5bd8e..5d56636 100644 --- a/query/src/main/java/com/blipblipcode/query/UnionQuery.kt +++ b/query/src/main/java/com/blipblipcode/query/UnionQuery.kt @@ -176,3 +176,22 @@ class UnionQuery private constructor( } } } +fun main() { + // Example usage: + val query1 = QuerySelect.builder("users") + .where(SQLOperator.Equals("age", 30)) + .and(SQLOperator.Equals("id", 30)).orderBy( + OrderBy.Asc("name")).build() + val query2 = QuerySelect.builder("admins") + .where(SQLOperator.Equals("edad", 30)) + .and(SQLOperator.Equals("ege", 30)) + .limit(10) + .orderBy(OrderBy.Desc("id")).build() + + val unionQuery = UnionQuery.builder(query1) + .addQuery(query2) + .unionAll() + .build() + + println(unionQuery.asSql()) +} \ No newline at end of file diff --git a/query/src/main/java/com/blipblipcode/query/operator/SQLOperator.kt b/query/src/main/java/com/blipblipcode/query/operator/SQLOperator.kt index b4c4368..6896c22 100644 --- a/query/src/main/java/com/blipblipcode/query/operator/SQLOperator.kt +++ b/query/src/main/java/com/blipblipcode/query/operator/SQLOperator.kt @@ -152,8 +152,8 @@ sealed interface SQLOperator { override fun toSQLString(): String { val startStr = caseConversion.asSqlFunction(start.toString()) val endStr = caseConversion.asSqlFunction(end.toString()) - return "${caseConversion.asSqlFunction(column)} $symbol $startStr AND $endStr" + return "${caseConversion.asSqlFunction(column)} $symbol '$startStr' AND '$endStr'" } - override fun asString(): String = "${caseConversion.asSqlFunction(column)} $symbol ${caseConversion.asSqlFunction(start.toString())} AND ${caseConversion.asSqlFunction(start.toString())}" + override fun asString(): String = "${caseConversion.asSqlFunction(column)} $symbol '${caseConversion.asSqlFunction(start.toString())}' AND '${caseConversion.asSqlFunction(start.toString())}'" } } From 135aed35f57850326a5c05746ad82fa85aeb3116 Mon Sep 17 00:00:00 2001 From: Leandro Colmenarez <89696212+LeandroLCD@users.noreply.github.com> Date: Fri, 26 Dec 2025 15:35:20 -0300 Subject: [PATCH 23/40] chore(deps): Update AGP to 8.13.2 feat(Query): Enhance `QuerySelect` and `UnionQuery` with builders and transformations This commit introduces several improvements to the query building API, focusing on query mutability and builder reusability. Key changes include: * **Enhanced `QuerySelect`**: * Added `newBuilder()` to create a builder from an existing query instance, facilitating query modification. * Updated `where` clause storage to use a `Pair>` to support keyed identification of the primary condition. * Added `getOperations()` to retrieve a map of all SQL operators (including WHERE) indexed by their keys. * Introduced `transformOperation(key, transform)` in `QueryBuilder` to allow modifying specific logical operations by key. * **Improved `UnionQuery`**: * Renamed `UnionQuery.Builder` to `UnionQuery.QueryBuilder` for consistency. * Added `newBuilder()` to allow extending or modifying existing union queries. * Implemented `transformOperation(key, transform)` in the union builder, which applies the transformation recursively to all subqueries in the union. * **Bug Fixes & Refactoring**: * Corrected `QuerySelect` SQL generation to handle the updated `where` pair structure. * Updated the `UnionQuery.addQuery` extension function to use the renamed `QueryBuilder`. --- gradle/libs.versions.toml | 2 +- .../com/blipblipcode/query/QuerySelect.kt | 75 +++++++++++++++++-- .../java/com/blipblipcode/query/UnionQuery.kt | 58 +++++++++++--- .../blipblipcode/query/utils/Extensions.kt | 2 +- 4 files changed, 118 insertions(+), 19 deletions(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 3ec804f..cb082f5 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,5 +1,5 @@ [versions] -agp = "8.13.1" +agp = "8.13.2" kotlin = "2.2.21" coreKtx = "1.17.0" junit = "4.13.2" diff --git a/query/src/main/java/com/blipblipcode/query/QuerySelect.kt b/query/src/main/java/com/blipblipcode/query/QuerySelect.kt index 59c98ee..f8455f4 100644 --- a/query/src/main/java/com/blipblipcode/query/QuerySelect.kt +++ b/query/src/main/java/com/blipblipcode/query/QuerySelect.kt @@ -17,7 +17,7 @@ import com.blipblipcode.query.operator.SQLOperator * @property fields The list of columns to be returned in the result set. Defaults to "*". */ class QuerySelect private constructor( - private var where: SQLOperator<*>?, + private var where: Pair>?, private val table: String, private val operations: LinkedHashMap, private val fields: List @@ -37,6 +37,21 @@ class QuerySelect private constructor( return QueryBuilder(table, LinkedHashMap()) } } + /** + * Creates a new `QueryBuilder` instance initialized with the current state of this `QuerySelect`. + * @param consumer A lambda that receives the `QueryBuilder` to customize the new query. + * @return A new `QueryBuilder` instance. + */ + fun newBuilder(consumer:(QueryBuilder)-> Unit): QueryBuilder { + val builder = QueryBuilder(table, operations) + where?.let { builder.where(it.first, it.second) } + builder.setFields(*fields.toTypedArray()) + orderBy?.let { builder.orderBy(it) } + limit?.let { builder.limit(it) } + return builder.apply { + consumer.invoke(this) + } + } /** * Removes a logical operation by its key. @@ -64,7 +79,15 @@ class QuerySelect private constructor( * @return The current `QuerySelect` instance for chaining. */ fun setWhere(operator: SQLOperator<*>): QuerySelect { - where = operator + where = operator.column to operator + return this + }/** + * Sets or replaces the main WHERE clause of the query. + * @param operator The new SQL operator for the WHERE clause. + * @return The current `QuerySelect` instance for chaining. + */ + fun setWhere(key: String, operator: SQLOperator<*>): QuerySelect { + where = key to operator return this } @@ -100,13 +123,20 @@ class QuerySelect private constructor( override fun getSqlOperators(): List> { return buildList { - where?.let { add(it) } + where?.let { add(it.second) } operations.values.forEach { add(it.operator) } orderBy?.let { add(it) } limit?.let { add(it) } } } + fun getOperations(): Map> { + return buildMap { + where?.let { put(it.first, it.second) } + operations.forEach { put(it.key, it.value.operator) } + } + } + override fun getTableName(): String { return table } @@ -126,7 +156,7 @@ class QuerySelect private constructor( if (where == null) { append("SELECT $fieldStr FROM $table") }else{ - append("SELECT $fieldStr FROM $table WHERE ${where?.toSQLString()} $operationsStr".trim()) + append("SELECT $fieldStr FROM $table WHERE ${where?.second?.toSQLString()} $operationsStr".trim()) } if (orderBy != null) { append(" ") @@ -150,7 +180,7 @@ class QuerySelect private constructor( if (where == null) { append("SELECT $fieldStr FROM $table") }else{ - append("SELECT $fieldStr FROM $table WHERE ${where?.toSQLString()} $operationsStr".trim()) + append("SELECT $fieldStr FROM $table WHERE ${where?.second?.toSQLString()} $operationsStr".trim()) } if (orderBy != null) { append(" ") @@ -211,7 +241,7 @@ class QuerySelect private constructor( private val table: String, private val operations: LinkedHashMap ) { - private var where: SQLOperator<*>? = null + private var where: Pair>? = null private var fields: List = listOf("*") private var orderBy: OrderBy? = null private var limit: Limit? = null @@ -324,7 +354,17 @@ class QuerySelect private constructor( * @return The `QueryBuilder` instance for chaining. */ fun where(operator: SQLOperator<*>): QueryBuilder { - where = operator + where = operator.column to operator + return this + } + + /** + * Sets the main WHERE clause for the query. + * @param operator The SQL operator for the WHERE clause. + * @return The `QueryBuilder` instance for chaining. + */ + fun where(key: String, operator: SQLOperator<*>): QueryBuilder { + where = key to operator return this } @@ -376,6 +416,27 @@ class QuerySelect private constructor( return this } + /** + * Transforms an existing logical operation by its key. + * @param key The key of the logical operation to transform. + * @param transform A lambda that takes the existing LogicalOperation and returns a new one. + * @return The `QueryBuilder` instance for chaining. + */ + fun transformOperation(key:String, transform: (LogicalOperation) -> LogicalOperation): QueryBuilder { + when { + operations.containsKey(key) -> { + val operator = operations[key] + val newOperations = transform(operator!!) + operations[key] = newOperations + } + where?.first == key -> { + val newOperations = transform(LogicalOperation(LogicalType.AND, where!!.second)) + where = where?.copy(second = newOperations.operator) + } + else -> Unit + } + return this + } /** * Builds the `QuerySelect` instance. * @return A new `QuerySelect` object. diff --git a/query/src/main/java/com/blipblipcode/query/UnionQuery.kt b/query/src/main/java/com/blipblipcode/query/UnionQuery.kt index 5d56636..2022422 100644 --- a/query/src/main/java/com/blipblipcode/query/UnionQuery.kt +++ b/query/src/main/java/com/blipblipcode/query/UnionQuery.kt @@ -1,7 +1,11 @@ package com.blipblipcode.query +import com.blipblipcode.query.QuerySelect.QueryBuilder +import com.blipblipcode.query.operator.LogicalOperation +import com.blipblipcode.query.operator.LogicalType import com.blipblipcode.query.operator.OrderBy import com.blipblipcode.query.operator.SQLOperator +import kotlin.collections.set /** * Represents a SQL UNION query construct. @@ -94,21 +98,55 @@ class UnionQuery private constructor( fun getOrderBy(): OrderBy? { return orderBy } - + /** + * Creates a new `QueryBuilder` initialized with the current state of this `UnionQuery`. + * This allows for further modifications or additions to the existing union query. + * + * @param consumer A lambda that receives the `QueryBuilder` for further configuration. + * @return A new `QueryBuilder` instance initialized with the current queries and union type. + */ + fun newBuilder(consumer:(QueryBuilder)-> Unit): QueryBuilder { + val builder = QueryBuilder() + builder.addQueries(queries) + if(useUnionAll){ + builder.unionAll() + }else{ + builder.union() + } + consumer(builder) + return builder + } /** * A builder for creating `UnionQuery` instances. * This class provides a fluent API to construct a UNION query. */ - class Builder { + class QueryBuilder { private val queries = mutableListOf() private var useUnionAll = false + + /** + * Transforms an existing logical operation by its key. + * @param key The key of the logical operation to transform. + * @param transform A lambda that takes the existing LogicalOperation and returns a new one. + * @return The `QueryBuilder` instance for chaining. + */ + fun transformOperation(key:String, transform: (LogicalOperation) -> LogicalOperation): QueryBuilder { + queries.forEachIndexed { index, querySelect -> + val newQuery = querySelect.newBuilder { qb -> + qb.transformOperation(key, transform) + }.build() + queries[index] = newQuery + } + return this + } + /** * Adds a query to the union. * @param query The `QuerySelect` to add. * @return The `Builder` instance for chaining. */ - fun addQuery(query: QuerySelect): Builder { + fun addQuery(query: QuerySelect): QueryBuilder { queries.add(query) return this } @@ -118,7 +156,7 @@ class UnionQuery private constructor( * @param queries The list of `QuerySelect` objects to add. * @return The `Builder` instance for chaining. */ - fun addQueries(queries: List): Builder { + fun addQueries(queries: List): QueryBuilder { this.queries.addAll(queries) return this } @@ -127,7 +165,7 @@ class UnionQuery private constructor( * Sets the union type to UNION ALL. * @return The `Builder` instance for chaining. */ - fun unionAll(): Builder { + fun unionAll(): QueryBuilder { useUnionAll = true return this } @@ -136,7 +174,7 @@ class UnionQuery private constructor( * Sets the union type to UNION (default). * @return The `Builder` instance for chaining. */ - fun union(): Builder { + fun union(): QueryBuilder { useUnionAll = false return this } @@ -158,8 +196,8 @@ class UnionQuery private constructor( * @param baseQuery The first `QuerySelect` in the union. * @return A new `Builder` instance. */ - fun builder(baseQuery: QuerySelect): Builder { - return Builder().also { builder -> + fun builder(baseQuery: QuerySelect): QueryBuilder { + return QueryBuilder().also { builder -> builder.addQuery(baseQuery) } } @@ -169,8 +207,8 @@ class UnionQuery private constructor( * @param baseQuery The first `QuerySelect` in the union. * @return A new `Builder` instance configured for UNION ALL. */ - fun builderAll(baseQuery: QuerySelect): Builder { - return Builder().also { builder -> + fun builderAll(baseQuery: QuerySelect): QueryBuilder { + return QueryBuilder().also { builder -> builder.addQuery(baseQuery).unionAll() } } diff --git a/query/src/main/java/com/blipblipcode/query/utils/Extensions.kt b/query/src/main/java/com/blipblipcode/query/utils/Extensions.kt index f01b7b7..b8a1aac 100644 --- a/query/src/main/java/com/blipblipcode/query/utils/Extensions.kt +++ b/query/src/main/java/com/blipblipcode/query/utils/Extensions.kt @@ -81,7 +81,7 @@ fun QuerySelect.unionAll(other: QuerySelect): UnionQuery { * @return A new `UnionQuery` instance containing the new query. */ fun UnionQuery.addQuery(query: QuerySelect): UnionQuery { - return UnionQuery.Builder() + return UnionQuery.QueryBuilder() .addQueries(this.queries) .addQuery(query) .apply { if (this@addQuery.useUnionAll) unionAll() else union() } From 2eac0b0d7b04cc2cf86955ae59367f740a6d435e Mon Sep 17 00:00:00 2001 From: Leandro Colmenarez <89696212+LeandroLCD@users.noreply.github.com> Date: Fri, 26 Dec 2025 15:35:45 -0300 Subject: [PATCH 24/40] chore(UnionQuery): Remove unused imports This commit cleans up the `UnionQuery.kt` file by removing several unused imports, including `QuerySelect.QueryBuilder`, `LogicalType`, and `kotlin.collections.set`. --- query/src/main/java/com/blipblipcode/query/UnionQuery.kt | 3 --- 1 file changed, 3 deletions(-) diff --git a/query/src/main/java/com/blipblipcode/query/UnionQuery.kt b/query/src/main/java/com/blipblipcode/query/UnionQuery.kt index 2022422..603f8ee 100644 --- a/query/src/main/java/com/blipblipcode/query/UnionQuery.kt +++ b/query/src/main/java/com/blipblipcode/query/UnionQuery.kt @@ -1,11 +1,8 @@ package com.blipblipcode.query -import com.blipblipcode.query.QuerySelect.QueryBuilder import com.blipblipcode.query.operator.LogicalOperation -import com.blipblipcode.query.operator.LogicalType import com.blipblipcode.query.operator.OrderBy import com.blipblipcode.query.operator.SQLOperator -import kotlin.collections.set /** * Represents a SQL UNION query construct. From 3b6c776b67dadb5ededae8839c82c54199e64cc7 Mon Sep 17 00:00:00 2001 From: Leandro Colmenarez <89696212+LeandroLCD@users.noreply.github.com> Date: Thu, 22 Jan 2026 11:41:25 -0300 Subject: [PATCH 25/40] refactor(Query): Standardize LogicalOperation with a sealed interface This commit refactors the `LogicalOperation` class into a sealed interface with distinct implementations for each logical type (`Where`, `And`, `Or`, `Not`, etc.). This improves type safety and aligns the structure with other operator models in the library. The key changes include: * **`LogicalOperation` as a Sealed Interface**: Replaced the previous data class with a sealed interface, providing specific data classes for each logical operation (`Where`, `And`, `Or`, etc.). * **Standardized Structure**: Each `LogicalOperation` implementation now includes `symbol`, `operator`, `asString()`, and `clone()` members for consistency. * **Updated Query Builders**: `QuerySelect.QueryBuilder` and `QueryDelete.QueryBuilder` have been updated to use the new sealed interface implementations, replacing the previous `LogicalType` enum. * **Refined SQL Generation**: The `asSql()` methods in `QuerySelect` and `QueryDelete` now delegate the generation of the `WHERE` clause to the `LogicalOperation.Where.asString()` method, simplifying the logic. --- .../com/blipblipcode/query/QueryDelete.kt | 31 ++--- .../com/blipblipcode/query/QuerySelect.kt | 118 +++++++++++++----- .../java/com/blipblipcode/query/UnionQuery.kt | 42 ++++--- .../query/operator/LogicalOperation.kt | 101 ++++++++++++++- .../query/operator/LogicalType.kt | 1 + .../com/blipblipcode/query/QueryDeleteTest.kt | 2 +- .../com/blipblipcode/query/QuerySelectTest.kt | 8 +- 7 files changed, 231 insertions(+), 72 deletions(-) diff --git a/query/src/main/java/com/blipblipcode/query/QueryDelete.kt b/query/src/main/java/com/blipblipcode/query/QueryDelete.kt index 2cd5ff2..7e2fefa 100644 --- a/query/src/main/java/com/blipblipcode/query/QueryDelete.kt +++ b/query/src/main/java/com/blipblipcode/query/QueryDelete.kt @@ -1,5 +1,6 @@ package com.blipblipcode.query +import android.provider.Telephony import com.blipblipcode.query.operator.LogicalOperation import com.blipblipcode.query.operator.LogicalType import com.blipblipcode.query.operator.SQLOperator @@ -14,7 +15,7 @@ import com.blipblipcode.query.operator.SQLOperator * @property operations A map of logical operations (AND, OR, etc.) to be appended to the WHERE clause. */ class QueryDelete private constructor( - private var where: SQLOperator<*>?, + private var where: Pair?, private val table: String, private val operations: LinkedHashMap, ):Queryable { @@ -54,7 +55,7 @@ class QueryDelete private constructor( * @return The current `QueryDelete` instance for chaining. */ fun setWhere(operator: SQLOperator<*>): QueryDelete { - where = operator + where = operator.column to LogicalOperation.Where(operator) return this } @@ -72,7 +73,7 @@ class QueryDelete private constructor( override fun getSqlOperators(): List> { return buildList { require(where != null) { "A WHERE clause must be specified." } - add(where!!) + add(where!!.second.operator) operations.values.forEach { add(it.operator) } } } @@ -92,7 +93,7 @@ class QueryDelete private constructor( */ override fun asSql(): String { require(where != null) { "A WHERE clause must be specified." } - return "DELETE FROM $table WHERE ${where!!.toSQLString()} ${operations.values.joinToString(" ") { it.asString() }}".trim() + return "DELETE FROM $table ${where!!.second.asString()} ${operations.values.joinToString(" ") { it.asString() }}".trim() } /** @@ -103,7 +104,7 @@ class QueryDelete private constructor( */ override fun asSql(predicate: (SQLOperator<*>) -> Boolean): String { require(where != null) { "A WHERE clause must be specified." } - return "DELETE FROM $table WHERE ${where!!.toSQLString()} ${operations.values.filter { predicate(it.operator) }.joinToString(" ") { it.asString() }}".trim() + return "DELETE FROM $table ${where!!.second.asString()} ${operations.values.filter { predicate(it.operator) }.joinToString(" ") { it.asString() }}".trim() } /** @@ -113,7 +114,7 @@ class QueryDelete private constructor( class QueryBuilder internal constructor( private val table: String, private val operations: LinkedHashMap ) { - private var where: SQLOperator<*>? = null + private var where: Pair? = null /** * Clears all conditions and resets the builder. @@ -132,7 +133,7 @@ class QueryDelete private constructor( * @return The `QueryBuilder` instance for chaining. */ fun and(key: String, operator: SQLOperator<*>): QueryBuilder { - operations[key] = LogicalOperation(LogicalType.AND, operator) + operations[key] = LogicalOperation.And(operator) return this } /** @@ -141,7 +142,7 @@ class QueryDelete private constructor( * @return The `QueryBuilder` instance for chaining. */ fun and(operator: SQLOperator<*>): QueryBuilder { - operations[operator.column] = LogicalOperation(LogicalType.AND, operator) + operations[operator.column] = LogicalOperation.And(operator) return this } @@ -151,7 +152,7 @@ class QueryDelete private constructor( * @return The `QueryBuilder` instance for chaining. */ fun andNot(operator: SQLOperator<*>): QueryBuilder { - operations[operator.column] = LogicalOperation(LogicalType.AND_NOT, operator) + operations[operator.column] = LogicalOperation.And(operator) return this } @@ -161,7 +162,7 @@ class QueryDelete private constructor( * @return The `QueryBuilder` instance for chaining. */ fun exists(operator: SQLOperator<*>): QueryBuilder { - operations[operator.column] = LogicalOperation(LogicalType.EXISTS, operator) + operations[operator.column] = LogicalOperation.Exists( operator) return this } @@ -171,7 +172,7 @@ class QueryDelete private constructor( * @return The `QueryBuilder` instance for chaining. */ fun not(operator: SQLOperator<*>): QueryBuilder { - operations[operator.column] = LogicalOperation(LogicalType.NOT, operator) + operations[operator.column] = LogicalOperation.Not(operator) return this } @@ -182,7 +183,7 @@ class QueryDelete private constructor( * @return The `QueryBuilder` instance for chaining. */ fun or(key: String, operator: SQLOperator<*>): QueryBuilder { - operations[key] = LogicalOperation(LogicalType.OR, operator) + operations[key] = LogicalOperation.Or(operator) return this } @@ -193,7 +194,7 @@ class QueryDelete private constructor( * @return The `QueryBuilder` instance for chaining. */ fun like(key: String, operator: SQLOperator.Like): QueryBuilder { - operations[key] = LogicalOperation(LogicalType.AND, operator) + operations[key] = LogicalOperation.And( operator) return this } @@ -204,7 +205,7 @@ class QueryDelete private constructor( * @return The `QueryBuilder` instance for chaining. */ fun all(key: String, operator: SQLOperator<*>): QueryBuilder { - operations[key] = LogicalOperation(LogicalType.ALL, operator) + operations[key] = LogicalOperation.All(operator) return this } @@ -224,7 +225,7 @@ class QueryDelete private constructor( * @return The `QueryBuilder` instance for chaining. */ fun where(operator: SQLOperator<*>): QueryBuilder { - where = operator + where = operator.column to LogicalOperation.Where(operator) return this } diff --git a/query/src/main/java/com/blipblipcode/query/QuerySelect.kt b/query/src/main/java/com/blipblipcode/query/QuerySelect.kt index f8455f4..5e5da74 100644 --- a/query/src/main/java/com/blipblipcode/query/QuerySelect.kt +++ b/query/src/main/java/com/blipblipcode/query/QuerySelect.kt @@ -17,7 +17,7 @@ import com.blipblipcode.query.operator.SQLOperator * @property fields The list of columns to be returned in the result set. Defaults to "*". */ class QuerySelect private constructor( - private var where: Pair>?, + private var where: Pair?, private val table: String, private val operations: LinkedHashMap, private val fields: List @@ -37,12 +37,13 @@ class QuerySelect private constructor( return QueryBuilder(table, LinkedHashMap()) } } + /** * Creates a new `QueryBuilder` instance initialized with the current state of this `QuerySelect`. * @param consumer A lambda that receives the `QueryBuilder` to customize the new query. * @return A new `QueryBuilder` instance. */ - fun newBuilder(consumer:(QueryBuilder)-> Unit): QueryBuilder { + fun newBuilder(consumer: (QueryBuilder) -> Unit): QueryBuilder { val builder = QueryBuilder(table, operations) where?.let { builder.where(it.first, it.second) } builder.setFields(*fields.toTypedArray()) @@ -79,15 +80,17 @@ class QuerySelect private constructor( * @return The current `QuerySelect` instance for chaining. */ fun setWhere(operator: SQLOperator<*>): QuerySelect { - where = operator.column to operator + where = operator.column to LogicalOperation.Where(operator) return this - }/** + } + + /** * Sets or replaces the main WHERE clause of the query. * @param operator The new SQL operator for the WHERE clause. * @return The current `QuerySelect` instance for chaining. */ fun setWhere(key: String, operator: SQLOperator<*>): QuerySelect { - where = key to operator + where = key to LogicalOperation.Where(operator) return this } @@ -123,7 +126,7 @@ class QuerySelect private constructor( override fun getSqlOperators(): List> { return buildList { - where?.let { add(it.second) } + where?.let { add(it.second.operator) } operations.values.forEach { add(it.operator) } orderBy?.let { add(it) } limit?.let { add(it) } @@ -132,7 +135,7 @@ class QuerySelect private constructor( fun getOperations(): Map> { return buildMap { - where?.let { put(it.first, it.second) } + where?.let { put(it.first, it.second.operator) } operations.forEach { put(it.key, it.value.operator) } } } @@ -151,12 +154,13 @@ class QuerySelect private constructor( */ override fun asSql(): String { val fieldStr = if (fields.isEmpty()) "*" else fields.joinToString(", ") - val operationsStr = if (operations.isNotEmpty()) operations.values.joinToString(" ") { it.asString() } else "" + val operationsStr = + if (operations.isNotEmpty()) operations.values.joinToString(" ") { it.asString() } else "" return buildString { if (where == null) { append("SELECT $fieldStr FROM $table") - }else{ - append("SELECT $fieldStr FROM $table WHERE ${where?.second?.toSQLString()} $operationsStr".trim()) + } else { + append("SELECT $fieldStr FROM $table ${where?.second?.asString()} $operationsStr".trim()) } if (orderBy != null) { append(" ") @@ -168,19 +172,22 @@ class QuerySelect private constructor( } } } + /** * Generates the SQL string for the SELECT statement. * @param predicate The predicate to filter the operators. * @return The complete SELECT SQL query as a string. */ - override fun asSql(predicate: ( SQLOperator<*>) -> Boolean): String { + override fun asSql(predicate: (SQLOperator<*>) -> Boolean): String { val fieldStr = if (fields.isEmpty()) "*" else fields.joinToString(", ") - val operationsStr = if (operations.isNotEmpty()) operations.values.filter { predicate(it.operator) }.joinToString(" ") { it.asString() } else "" + val operationsStr = + if (operations.isNotEmpty()) operations.values.filter { predicate(it.operator) } + .joinToString(" ") { it.asString() } else "" return buildString { if (where == null) { append("SELECT $fieldStr FROM $table") - }else{ - append("SELECT $fieldStr FROM $table WHERE ${where?.second?.toSQLString()} $operationsStr".trim()) + } else { + append("SELECT $fieldStr FROM $table ${where?.second?.asString()} $operationsStr".trim()) } if (orderBy != null) { append(" ") @@ -241,11 +248,20 @@ class QuerySelect private constructor( private val table: String, private val operations: LinkedHashMap ) { - private var where: Pair>? = null + private var where: Pair? = null private var fields: List = listOf("*") private var orderBy: OrderBy? = null private var limit: Limit? = null + /** + * Retrieves the current SQL operator for a given key. + * @param key The key of the SQL operator to retrieve. + * @return The `SQLOperator` if found, otherwise null. + */ + fun getSqlOperation(key: String): SQLOperator<*>? { + return operations.get(key)?.operator + } + /** * Clears all logical operations and the main WHERE clause from the query. * @return The current `QuerySelect` instance for chaining. @@ -263,16 +279,17 @@ class QuerySelect private constructor( * @return The `QueryBuilder` instance for chaining. */ fun and(key: String, operator: SQLOperator<*>): QueryBuilder { - operations[key] = LogicalOperation(LogicalType.AND, operator) + operations[key] = LogicalOperation.And(operator) return this } + /** * Adds an AND condition to the WHERE clause. * @param operator The SQL operator for this condition. * @return The `QueryBuilder` instance for chaining. */ fun and(operator: SQLOperator<*>): QueryBuilder { - operations[operator.column] = LogicalOperation(LogicalType.AND, operator) + operations[operator.column] = LogicalOperation.And(operator) return this } @@ -282,7 +299,7 @@ class QuerySelect private constructor( * @return The `QueryBuilder` instance for chaining. */ fun andNot(operator: SQLOperator<*>): QueryBuilder { - operations[operator.column] = LogicalOperation(LogicalType.AND_NOT, operator) + operations[operator.column] = LogicalOperation.AndNot(operator) return this } @@ -292,7 +309,7 @@ class QuerySelect private constructor( * @return The `QueryBuilder` instance for chaining. */ fun exists(operator: SQLOperator<*>): QueryBuilder { - operations[operator.column] = LogicalOperation(LogicalType.EXISTS, operator) + operations[operator.column] = LogicalOperation.Exists(operator) return this } @@ -302,9 +319,10 @@ class QuerySelect private constructor( * @return The `QueryBuilder` instance for chaining. */ fun not(operator: SQLOperator<*>): QueryBuilder { - operations[operator.column] = LogicalOperation(LogicalType.NOT, operator) + operations[operator.column] = LogicalOperation.Not(operator) return this } + /** * Adds an OR condition to the WHERE clause. * @param key A unique key for this condition. @@ -312,7 +330,7 @@ class QuerySelect private constructor( * @return The `QueryBuilder` instance for chaining. */ fun or(key: String, operator: SQLOperator<*>): QueryBuilder { - operations[key] = LogicalOperation(LogicalType.OR, operator) + operations[key] = LogicalOperation.Or(operator) return this } @@ -323,7 +341,7 @@ class QuerySelect private constructor( * @return The `QueryBuilder` instance for chaining. */ fun like(key: String, operator: SQLOperator.Like): QueryBuilder { - operations[key] = LogicalOperation(LogicalType.AND, operator) + operations[key] = LogicalOperation.And(operator) return this } @@ -334,7 +352,7 @@ class QuerySelect private constructor( * @return The `QueryBuilder` instance for chaining. */ fun all(key: String, operator: SQLOperator<*>): QueryBuilder { - operations[key] = LogicalOperation(LogicalType.ALL, operator) + operations[key] = LogicalOperation.All(operator) return this } @@ -348,13 +366,24 @@ class QuerySelect private constructor( return this } + /** + * Adds a logical operation. + * @param key A unique key for the logical operation. + * @param operation The logical operation to add. + * @return The `QueryBuilder` instance for chaining. + */ + fun addLogicalOperation(key: String, operation: LogicalOperation): QueryBuilder { + operations[key] = operation + return this + } + /** * Sets the main WHERE clause for the query. * @param operator The SQL operator for the WHERE clause. * @return The `QueryBuilder` instance for chaining. */ fun where(operator: SQLOperator<*>): QueryBuilder { - where = operator.column to operator + where = operator.column to LogicalOperation.Where(operator) return this } @@ -364,6 +393,11 @@ class QuerySelect private constructor( * @return The `QueryBuilder` instance for chaining. */ fun where(key: String, operator: SQLOperator<*>): QueryBuilder { + where = key to LogicalOperation.Where(operator) + return this + } + + fun where(key: String, operator: LogicalOperation): QueryBuilder { where = key to operator return this } @@ -387,6 +421,7 @@ class QuerySelect private constructor( this.orderBy = orderBy return this } + /** * Returns the ORDER BY clause for the query. * @return The OrderBy object, or null if not set. @@ -422,28 +457,34 @@ class QuerySelect private constructor( * @param transform A lambda that takes the existing LogicalOperation and returns a new one. * @return The `QueryBuilder` instance for chaining. */ - fun transformOperation(key:String, transform: (LogicalOperation) -> LogicalOperation): QueryBuilder { + fun transformOperation( + key: String, + transform: (LogicalOperation) -> LogicalOperation + ): QueryBuilder { when { operations.containsKey(key) -> { val operator = operations[key] val newOperations = transform(operator!!) operations[key] = newOperations } + where?.first == key -> { - val newOperations = transform(LogicalOperation(LogicalType.AND, where!!.second)) - where = where?.copy(second = newOperations.operator) + val newOperations = transform(where!!.second) + where = where?.copy(second = newOperations) } + else -> Unit } return this } + /** * Builds the `QuerySelect` instance. * @return A new `QuerySelect` object. * @throws IllegalArgumentException if the WHERE clause is not set. */ fun build(): QuerySelect { - if(operations.isNotEmpty()){ + if (operations.isNotEmpty()) { require(where != null) { "WHERE clause is required for QuerySelect" } } return QuerySelect( @@ -458,3 +499,24 @@ class QuerySelect private constructor( } } } + +fun main() { + val query = QuerySelect.builder("users") + .where("status", SQLOperator.In("status", listOf(1, 5))) + .limit(10) + .build() + val newQuery = query.newBuilder { builder -> + builder.transformOperation("status") { op -> + LogicalOperation.Multiple( + listOf( + op, + LogicalOperation.Or(SQLOperator.Equals("status", 3)) + ), + "WHERE" + ) + } + }.build() + + println(newQuery.asSql()) + +} \ No newline at end of file diff --git a/query/src/main/java/com/blipblipcode/query/UnionQuery.kt b/query/src/main/java/com/blipblipcode/query/UnionQuery.kt index 603f8ee..7cb006a 100644 --- a/query/src/main/java/com/blipblipcode/query/UnionQuery.kt +++ b/query/src/main/java/com/blipblipcode/query/UnionQuery.kt @@ -26,7 +26,7 @@ class UnionQuery private constructor( } override fun getSqlOperation(key: String): SQLOperator<*>? { - return queries.flatMap { it.getSqlOperators() }.firstOrNull { it.column.equals(key, ignoreCase = true) } + return queries.map { it.getSqlOperation(key) }.firstOrNull() } /** @@ -121,8 +121,29 @@ class UnionQuery private constructor( private val queries = mutableListOf() private var useUnionAll = false + /** + * Retrieves the current SQL operator for a given key. + * @param key The key of the SQL operator to retrieve. + * @return The `SQLOperator` if found, otherwise null. + */ + fun getSqlOperation(key: String): SQLOperator<*>? { + return queries.map { it.getSqlOperation(key) }.firstOrNull() + } /** + * Adds a logical operation to all queries in the union. + * @param key The key for the logical operation. + * @param operation The `LogicalOperation` to add. + * @return The `QueryBuilder` instance for chaining. + */ + fun addLogicalOperation(key: String, operation: LogicalOperation):QueryBuilder{ + queries.forEach { + it.addLogicalOperation(key, operation) + } + return this + } + + /** * Transforms an existing logical operation by its key. * @param key The key of the logical operation to transform. * @param transform A lambda that takes the existing LogicalOperation and returns a new one. @@ -211,22 +232,3 @@ class UnionQuery private constructor( } } } -fun main() { - // Example usage: - val query1 = QuerySelect.builder("users") - .where(SQLOperator.Equals("age", 30)) - .and(SQLOperator.Equals("id", 30)).orderBy( - OrderBy.Asc("name")).build() - val query2 = QuerySelect.builder("admins") - .where(SQLOperator.Equals("edad", 30)) - .and(SQLOperator.Equals("ege", 30)) - .limit(10) - .orderBy(OrderBy.Desc("id")).build() - - val unionQuery = UnionQuery.builder(query1) - .addQuery(query2) - .unionAll() - .build() - - println(unionQuery.asSql()) -} \ No newline at end of file diff --git a/query/src/main/java/com/blipblipcode/query/operator/LogicalOperation.kt b/query/src/main/java/com/blipblipcode/query/operator/LogicalOperation.kt index 4b9337b..323f98f 100644 --- a/query/src/main/java/com/blipblipcode/query/operator/LogicalOperation.kt +++ b/query/src/main/java/com/blipblipcode/query/operator/LogicalOperation.kt @@ -1,5 +1,6 @@ package com.blipblipcode.query.operator +import kotlin.collections.forEachIndexed /** @@ -9,13 +10,105 @@ package com.blipblipcode.query.operator * @property type The type of the logical operation (e.g., AND, OR). * @property operator The SQL operator that is part of the logical operation. */ -data class LogicalOperation( - val type: LogicalType, +sealed interface LogicalOperation{ + val symbol: String val operator: SQLOperator<*> -) { + + data class Where(override val operator: SQLOperator<*>) : LogicalOperation { + override val symbol: String = LogicalType.WHERE.sql + override fun asString(): String { + return "$symbol ${operator.toSQLString()}" + } + + override fun clone(vararg arg: Any?): LogicalOperation { + return Where(arg[0] as SQLOperator<*>) + } + } + data class And(override val operator: SQLOperator<*>) : LogicalOperation { + override val symbol: String = LogicalType.AND.sql + override fun asString(): String { + return "$symbol ${operator.toSQLString()}" + } + + override fun clone(vararg arg: Any?): LogicalOperation { + return And(arg[0] as SQLOperator<*>) + } + } + data class Or(override val operator: SQLOperator<*>) : LogicalOperation { + override val symbol: String = LogicalType.OR.sql + override fun asString(): String { + return "$symbol ${operator.toSQLString()}" + } + + override fun clone(vararg arg: Any?): LogicalOperation { + return Or(arg[0] as SQLOperator<*>) + } + } + data class AndNot(override val operator: SQLOperator<*>) : LogicalOperation { + override val symbol: String = LogicalType.AND_NOT.sql + override fun asString(): String { + return "$symbol ${operator.toSQLString()}" + } + override fun clone(vararg arg: Any?): LogicalOperation { + return And(arg[0] as SQLOperator<*>) + } + } + data class Exists(override val operator: SQLOperator<*>) : LogicalOperation{ + override val symbol: String = LogicalType.EXISTS.sql + override fun asString(): String { + return "$symbol ${operator.toSQLString()}" + } + override fun clone(vararg arg: Any?): LogicalOperation { + return Exists(arg[0] as SQLOperator<*>) + } + } + data class Not(override val operator: SQLOperator<*>) : LogicalOperation { + override val symbol: String = LogicalType.NOT.sql + override fun asString(): String { + return "$symbol ${operator.toSQLString()}" + } + override fun clone(vararg arg: Any?): LogicalOperation { + return Not(arg[0] as SQLOperator<*>) + } + } + data class All(override val operator: SQLOperator<*>) : LogicalOperation { + override val symbol: String = LogicalType.ALL.sql + override fun asString(): String { + return "$symbol ${operator.toSQLString()}" + } + override fun clone(vararg arg: Any?): LogicalOperation { + return All(arg[0] as SQLOperator<*>) + } + } + @Suppress("UNCHECKED_CAST") + data class Multiple(val operations: List, override val symbol: String = "AND") : LogicalOperation { + override val operator: SQLOperator<*> + get() = operations.first().operator + + override fun asString(): String { + return buildString { + append("$symbol (") + operations.forEachIndexed { index, logicalOperation -> + if (index == 0) { + append(logicalOperation.operator.asString()) + } else { + append(" ${logicalOperation.asString()}") + } + } + append(")") + + } + } + override fun clone(vararg arg: Any?): LogicalOperation { + return Multiple(arg[0] as List, symbol = arg[1] as? String ?: symbol) + } + } + /** * Converts the logical operation into its SQL string representation. * @return The SQL string for the logical operation (e.g., "AND age > '18'"). */ - fun asString(): String = "${type.sql} ${operator.toSQLString()}" + fun asString(): String + + fun clone(vararg arg: Any?): LogicalOperation } diff --git a/query/src/main/java/com/blipblipcode/query/operator/LogicalType.kt b/query/src/main/java/com/blipblipcode/query/operator/LogicalType.kt index 8ad523e..d6b804e 100644 --- a/query/src/main/java/com/blipblipcode/query/operator/LogicalType.kt +++ b/query/src/main/java/com/blipblipcode/query/operator/LogicalType.kt @@ -4,6 +4,7 @@ package com.blipblipcode.query.operator * Defines the types of logical operations that can be used in a SQL WHERE clause. */ enum class LogicalType(val sql: String) { + WHERE("WHERE"), /** Represents a logical AND operation. */ AND("AND"), /** Represents a logical OR operation. */ diff --git a/query/src/test/java/com/blipblipcode/query/QueryDeleteTest.kt b/query/src/test/java/com/blipblipcode/query/QueryDeleteTest.kt index fb0d7bf..6d0bb30 100644 --- a/query/src/test/java/com/blipblipcode/query/QueryDeleteTest.kt +++ b/query/src/test/java/com/blipblipcode/query/QueryDeleteTest.kt @@ -78,7 +78,7 @@ class QueryDeleteTest { .where(SQLOperator.Equals("id", 1)) .build() - query.addLogicalOperation("status", LogicalOperation(LogicalType.AND, SQLOperator.Equals("status", "inactive"))) + query.addLogicalOperation("status", LogicalOperation.And( SQLOperator.Equals("status", "inactive"))) val expectedSql = "DELETE FROM users WHERE id = 1 AND status = 'inactive'" assertEquals(expectedSql, query.asSql()) diff --git a/query/src/test/java/com/blipblipcode/query/QuerySelectTest.kt b/query/src/test/java/com/blipblipcode/query/QuerySelectTest.kt index 557a9b5..4abff45 100644 --- a/query/src/test/java/com/blipblipcode/query/QuerySelectTest.kt +++ b/query/src/test/java/com/blipblipcode/query/QuerySelectTest.kt @@ -97,7 +97,7 @@ class QuerySelectTest { val query = QuerySelect.builder("users") .where(SQLOperator.Equals("id", 1)) .build() - query.addLogicalOperation("status", LogicalOperation(LogicalType.AND, SQLOperator.Equals("status", "active"))) + query.addLogicalOperation("status", LogicalOperation.And(SQLOperator.Equals("status", "active"))) val expectedSql = "SELECT * FROM users WHERE id = 1 AND status = 'active'" assertEquals(expectedSql, query.asSql()) } @@ -108,7 +108,7 @@ class QuerySelectTest { .where(SQLOperator.Equals("id", 1)) .and("status", SQLOperator.Equals("status", "inactive")) .build() - query.addLogicalOperation("status", LogicalOperation(LogicalType.AND, SQLOperator.Equals("status", "active"))) + query.addLogicalOperation("status", LogicalOperation.And(SQLOperator.Equals("status", "active"))) val expectedSql = "SELECT * FROM users WHERE id = 1 AND status = 'active'" assertEquals(expectedSql, query.asSql()) } @@ -118,7 +118,7 @@ class QuerySelectTest { val query = QuerySelect.builder("users") .where(SQLOperator.Equals("id", 1)) .build() - query.addLogicalOperation("", LogicalOperation(LogicalType.AND, SQLOperator.Equals("status", "active"))) + query.addLogicalOperation("", LogicalOperation.And(SQLOperator.Equals("status", "active"))) val expectedSql = "SELECT * FROM users WHERE id = 1 AND status = 'active'" assertEquals(expectedSql, query.asSql()) } @@ -128,7 +128,7 @@ class QuerySelectTest { val query = QuerySelect.builder("users") .where(SQLOperator.Equals("id", 1)) .build() - .addLogicalOperation("age", LogicalOperation(LogicalType.OR, SQLOperator.GreaterThan("age", 30))) + .addLogicalOperation("age", LogicalOperation.Or(SQLOperator.GreaterThan("age", 30))) val expectedSql = "SELECT * FROM users WHERE id = 1 OR age > 30" assertEquals(expectedSql, query.asSql()) } From d77801608b1d6755071b6092acd279669febb7ec Mon Sep 17 00:00:00 2001 From: Leandro Colmenarez <89696212+LeandroLCD@users.noreply.github.com> Date: Thu, 22 Jan 2026 11:42:51 -0300 Subject: [PATCH 26/40] refactor(Query): Rename property 'type' to 'symbol' in LogicalOperation and remove unused imports --- query/src/main/java/com/blipblipcode/query/QueryDelete.kt | 2 -- query/src/main/java/com/blipblipcode/query/QuerySelect.kt | 1 - .../java/com/blipblipcode/query/operator/LogicalOperation.kt | 2 +- query/src/test/java/com/blipblipcode/query/QueryDeleteTest.kt | 1 - query/src/test/java/com/blipblipcode/query/QuerySelectTest.kt | 1 - 5 files changed, 1 insertion(+), 6 deletions(-) diff --git a/query/src/main/java/com/blipblipcode/query/QueryDelete.kt b/query/src/main/java/com/blipblipcode/query/QueryDelete.kt index 7e2fefa..23f2ea4 100644 --- a/query/src/main/java/com/blipblipcode/query/QueryDelete.kt +++ b/query/src/main/java/com/blipblipcode/query/QueryDelete.kt @@ -1,8 +1,6 @@ package com.blipblipcode.query -import android.provider.Telephony import com.blipblipcode.query.operator.LogicalOperation -import com.blipblipcode.query.operator.LogicalType import com.blipblipcode.query.operator.SQLOperator /** diff --git a/query/src/main/java/com/blipblipcode/query/QuerySelect.kt b/query/src/main/java/com/blipblipcode/query/QuerySelect.kt index 5e5da74..59196a1 100644 --- a/query/src/main/java/com/blipblipcode/query/QuerySelect.kt +++ b/query/src/main/java/com/blipblipcode/query/QuerySelect.kt @@ -2,7 +2,6 @@ package com.blipblipcode.query import com.blipblipcode.query.operator.Limit import com.blipblipcode.query.operator.LogicalOperation -import com.blipblipcode.query.operator.LogicalType import com.blipblipcode.query.operator.OrderBy import com.blipblipcode.query.operator.SQLOperator diff --git a/query/src/main/java/com/blipblipcode/query/operator/LogicalOperation.kt b/query/src/main/java/com/blipblipcode/query/operator/LogicalOperation.kt index 323f98f..5acc8cb 100644 --- a/query/src/main/java/com/blipblipcode/query/operator/LogicalOperation.kt +++ b/query/src/main/java/com/blipblipcode/query/operator/LogicalOperation.kt @@ -7,7 +7,7 @@ import kotlin.collections.forEachIndexed * Represents a logical operation in a SQL query, combining a [LogicalType] with a [SQLOperator]. * For example, "AND age > 18". * - * @property type The type of the logical operation (e.g., AND, OR). + * @property symbol The type of the logical operation (e.g., AND, OR). * @property operator The SQL operator that is part of the logical operation. */ sealed interface LogicalOperation{ diff --git a/query/src/test/java/com/blipblipcode/query/QueryDeleteTest.kt b/query/src/test/java/com/blipblipcode/query/QueryDeleteTest.kt index 6d0bb30..0715a6d 100644 --- a/query/src/test/java/com/blipblipcode/query/QueryDeleteTest.kt +++ b/query/src/test/java/com/blipblipcode/query/QueryDeleteTest.kt @@ -1,7 +1,6 @@ package com.blipblipcode.query import com.blipblipcode.query.operator.LogicalOperation -import com.blipblipcode.query.operator.LogicalType import com.blipblipcode.query.operator.SQLOperator import com.blipblipcode.query.utils.asSQLiteQuery import org.junit.Assert.assertEquals diff --git a/query/src/test/java/com/blipblipcode/query/QuerySelectTest.kt b/query/src/test/java/com/blipblipcode/query/QuerySelectTest.kt index 4abff45..aa695d3 100644 --- a/query/src/test/java/com/blipblipcode/query/QuerySelectTest.kt +++ b/query/src/test/java/com/blipblipcode/query/QuerySelectTest.kt @@ -2,7 +2,6 @@ package com.blipblipcode.query import com.blipblipcode.query.operator.CaseConversion import com.blipblipcode.query.operator.LogicalOperation -import com.blipblipcode.query.operator.LogicalType import com.blipblipcode.query.operator.OrderBy import com.blipblipcode.query.operator.SQLOperator import com.blipblipcode.query.utils.asSQLiteQuery From d1daaee34117081851d3f463c8a5720144ad9b8c Mon Sep 17 00:00:00 2001 From: Leandro Colmenarez <89696212+LeandroLCD@users.noreply.github.com> Date: Thu, 22 Jan 2026 12:00:34 -0300 Subject: [PATCH 27/40] refactor(Query): Remove unused main function and transformOperation method from QuerySelect and UnionQuery --- .../com/blipblipcode/query/QuerySelect.kt | 21 ------------------- .../java/com/blipblipcode/query/UnionQuery.kt | 16 -------------- 2 files changed, 37 deletions(-) diff --git a/query/src/main/java/com/blipblipcode/query/QuerySelect.kt b/query/src/main/java/com/blipblipcode/query/QuerySelect.kt index 9c02c3a..cda1533 100644 --- a/query/src/main/java/com/blipblipcode/query/QuerySelect.kt +++ b/query/src/main/java/com/blipblipcode/query/QuerySelect.kt @@ -497,25 +497,4 @@ class QuerySelect private constructor( } } } -} - -fun main() { - val query = QuerySelect.builder("users") - .where("status", SQLOperator.In("status", listOf(1, 5))) - .limit(10) - .build() - val newQuery = query.newBuilder { builder -> - builder.transformOperation("status") { op -> - LogicalOperation.Multiple( - listOf( - op, - LogicalOperation.Or(SQLOperator.Equals("status", 3)) - ), - "WHERE" - ) - } - }.build() - - println(newQuery.asSql()) - } \ No newline at end of file diff --git a/query/src/main/java/com/blipblipcode/query/UnionQuery.kt b/query/src/main/java/com/blipblipcode/query/UnionQuery.kt index 7c5b2af..6532c64 100644 --- a/query/src/main/java/com/blipblipcode/query/UnionQuery.kt +++ b/query/src/main/java/com/blipblipcode/query/UnionQuery.kt @@ -160,22 +160,6 @@ class UnionQuery private constructor( return this } - /** - * Transforms an existing logical operation by its key. - * @param key The key of the logical operation to transform. - * @param transform A lambda that takes the existing LogicalOperation and returns a new one. - * @return The `QueryBuilder` instance for chaining. - */ - fun transformOperation(key:String, transform: (LogicalOperation) -> LogicalOperation): QueryBuilder { - queries.forEachIndexed { index, querySelect -> - val newQuery = querySelect.newBuilder { qb -> - qb.transformOperation(key, transform) - }.build() - queries[index] = newQuery - } - return this - } - /** * Adds a query to the union. * @param query The `QuerySelect` to add. From b5a24dee14726d4b0df6a082491b0335ddd1ab52 Mon Sep 17 00:00:00 2001 From: Leandro Colmenarez <89696212+LeandroLCD@users.noreply.github.com> Date: Mon, 9 Feb 2026 11:53:07 -0300 Subject: [PATCH 28/40] fix(QuerySelect): Reset limit and orderBy in clear method --- query/src/main/java/com/blipblipcode/query/QuerySelect.kt | 2 ++ 1 file changed, 2 insertions(+) diff --git a/query/src/main/java/com/blipblipcode/query/QuerySelect.kt b/query/src/main/java/com/blipblipcode/query/QuerySelect.kt index cda1533..1c8cc25 100644 --- a/query/src/main/java/com/blipblipcode/query/QuerySelect.kt +++ b/query/src/main/java/com/blipblipcode/query/QuerySelect.kt @@ -70,6 +70,8 @@ class QuerySelect private constructor( fun clear(): QuerySelect { operations.clear() where = null + limit = null + orderBy = null return this } From 56ac9b8d7dd3d6d4e22236522051e8101ba06c27 Mon Sep 17 00:00:00 2001 From: Leandro Colmenarez <89696212+LeandroLCD@users.noreply.github.com> Date: Mon, 9 Feb 2026 12:24:01 -0300 Subject: [PATCH 29/40] feat(build.gradle.kts): Add Maven publishing configuration for release variant --- query/build.gradle.kts | 36 ++++++++++++++++++++++++++++++++++-- 1 file changed, 34 insertions(+), 2 deletions(-) diff --git a/query/build.gradle.kts b/query/build.gradle.kts index 4376e8e..bf22ef2 100644 --- a/query/build.gradle.kts +++ b/query/build.gradle.kts @@ -1,8 +1,11 @@ +import org.gradle.api.publish.maven.MavenPublication +import org.gradle.kotlin.dsl.publishing import org.jetbrains.kotlin.gradle.dsl.JvmTarget plugins { alias(libs.plugins.android.library) alias(libs.plugins.kotlin.android) + `maven-publish` } android { @@ -36,6 +39,37 @@ android { jvmTarget = JvmTarget.JVM_17 } } + + publishing { + singleVariant("release") { + withSourcesJar() + } + } +} + + +publishing { + publications { + create("release") { + groupId = "com.github.LeandroLCD" + artifactId = "query" + version = project.version.toString() + } + } +} + +afterEvaluate { + val releaseComponent = components.findByName("release") + if (releaseComponent != null) { + publishing { + publications { + val pub = getByName("release") as MavenPublication + pub.from(releaseComponent) + } + } + } else { + logger.warn("Android 'release' component not found; maven publication won't include component artifacts.") + } } dependencies { @@ -58,5 +92,3 @@ tasks.register("runQueryUnitTests") { println("✅ Tests unitarios del módulo query completados") } } - - From c61f619b30dd0262790d36b074c9c04781c3cfb4 Mon Sep 17 00:00:00 2001 From: Leandro Colmenarez <89696212+LeandroLCD@users.noreply.github.com> Date: Thu, 12 Feb 2026 12:30:37 -0300 Subject: [PATCH 30/40] chore(deps): Upgrade Gradle and Android Gradle Plugin - Upgrades the Android Gradle Plugin (AGP) from version 8.13.2 to 9.0.0. - Updates the Gradle wrapper to version 9.3.1. - Removes the now-redundant `kotlin.android` plugin from build scripts, as it's included in AGP 9.0. - Adjusts build script structures to align with the new AGP version by moving the `kotlin` block. - Adds `limit` and `orderBy` to the operations map and exposes a `getLimit()` method in `QuerySelect`. --- app/build.gradle.kts | 11 +++++------ build.gradle.kts | 1 - gradle.properties | 2 +- gradle/libs.versions.toml | 2 +- gradle/wrapper/gradle-wrapper.properties | 2 +- query/build.gradle.kts | 11 +++++------ .../main/java/com/blipblipcode/query/QuerySelect.kt | 6 ++++++ 7 files changed, 19 insertions(+), 16 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index f5d5594..32fe77c 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -2,7 +2,6 @@ import org.jetbrains.kotlin.gradle.dsl.JvmTarget plugins { alias(libs.plugins.android.application) - alias(libs.plugins.kotlin.android) alias(libs.plugins.kotlin.compose) } @@ -35,15 +34,15 @@ android { sourceCompatibility = JavaVersion.VERSION_17 targetCompatibility = JavaVersion.VERSION_17 } - kotlin{ - compilerOptions { - jvmTarget = JvmTarget.JVM_17 - } - } buildFeatures { compose = true } } +kotlin{ + compilerOptions { + jvmTarget = JvmTarget.JVM_17 + } +} dependencies { implementation(libs.androidx.core.ktx) diff --git a/build.gradle.kts b/build.gradle.kts index 5ea216f..11263cc 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,7 +1,6 @@ // Top-level build file where you can add configuration options common to all sub-projects/modules. plugins { alias(libs.plugins.android.application) apply false - alias(libs.plugins.kotlin.android) apply false alias(libs.plugins.kotlin.compose) apply false alias(libs.plugins.android.library) apply false } \ No newline at end of file diff --git a/gradle.properties b/gradle.properties index 20e2a01..132244e 100644 --- a/gradle.properties +++ b/gradle.properties @@ -20,4 +20,4 @@ kotlin.code.style=official # Enables namespacing of each library's R class so that its R class includes only the # resources declared in the library itself and none from the library's dependencies, # thereby reducing the size of the R class for that library -android.nonTransitiveRClass=true \ No newline at end of file +android.nonTransitiveRClass=true diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index cb082f5..7fb7b88 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,5 +1,5 @@ [versions] -agp = "8.13.2" +agp = "9.0.0" kotlin = "2.2.21" coreKtx = "1.17.0" junit = "4.13.2" diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 65b35ea..797c4e4 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,7 +1,7 @@ #Mon Oct 13 19:27:45 CLST 2025 distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.13-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-9.3.1-bin.zip networkTimeout=10000 validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME diff --git a/query/build.gradle.kts b/query/build.gradle.kts index bf22ef2..1cd3671 100644 --- a/query/build.gradle.kts +++ b/query/build.gradle.kts @@ -4,7 +4,6 @@ import org.jetbrains.kotlin.gradle.dsl.JvmTarget plugins { alias(libs.plugins.android.library) - alias(libs.plugins.kotlin.android) `maven-publish` } @@ -34,11 +33,6 @@ android { sourceCompatibility = JavaVersion.VERSION_17 targetCompatibility = JavaVersion.VERSION_17 } - kotlin{ - compilerOptions { - jvmTarget = JvmTarget.JVM_17 - } - } publishing { singleVariant("release") { @@ -46,6 +40,11 @@ android { } } } +kotlin{ + compilerOptions { + jvmTarget = JvmTarget.JVM_17 + } +} publishing { diff --git a/query/src/main/java/com/blipblipcode/query/QuerySelect.kt b/query/src/main/java/com/blipblipcode/query/QuerySelect.kt index 1c8cc25..68008b9 100644 --- a/query/src/main/java/com/blipblipcode/query/QuerySelect.kt +++ b/query/src/main/java/com/blipblipcode/query/QuerySelect.kt @@ -137,6 +137,8 @@ class QuerySelect private constructor( fun getOperations(): Map> { return buildMap { where?.let { put(it.first, it.second.operator) } + limit?.let { put("limit", it) } + orderBy?.let { put("orderBy", it) } operations.forEach { put(it.key, it.value.operator) } } } @@ -240,6 +242,10 @@ class QuerySelect private constructor( return this.orderBy } + fun getLimit(): Limit? { + return this.limit + } + /** * A builder for creating `QuerySelect` instances. From 97f48dc6d0adb438fa952173ec40c68c788a09f4 Mon Sep 17 00:00:00 2001 From: Leandro Colmenarez <89696212+LeandroLCD@users.noreply.github.com> Date: Thu, 12 Feb 2026 12:32:05 -0300 Subject: [PATCH 31/40] feat(deps): Update kotlin and compose dependencies Updates several dependencies to their newer versions: - Kotlin from 2.2.21 to 2.3.10 - Activity Compose from 1.12.1 to 1.12.4 - Compose BOM from 2025.12.00 to 2026.02.00 Additionally, removes the unused `kotlin-android` plugin definition from the `[plugins]` section. --- gradle/libs.versions.toml | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 7fb7b88..71892dc 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,13 +1,13 @@ [versions] agp = "9.0.0" -kotlin = "2.2.21" +kotlin = "2.3.10" coreKtx = "1.17.0" junit = "4.13.2" junitVersion = "1.3.0" espressoCore = "3.7.0" lifecycleRuntimeKtx = "2.10.0" -activityCompose = "1.12.1" -composeBom = "2025.12.00" +activityCompose = "1.12.4" +composeBom = "2026.02.00" appcompat = "1.7.1" material = "1.13.0" sqliteKtx = "2.6.2" @@ -33,7 +33,6 @@ androidx-sqlite-ktx = { group = "androidx.sqlite", name = "sqlite-ktx", version. [plugins] android-application = { id = "com.android.application", version.ref = "agp" } -kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } android-library = { id = "com.android.library", version.ref = "agp" } From 92713cd5b44513b82020cd6652582fbcbefc75da Mon Sep 17 00:00:00 2001 From: LeandroLCD Date: Tue, 17 Mar 2026 12:46:39 -0300 Subject: [PATCH 32/40] feat(deps): Update AndroidX and Compose dependencies Updates several dependencies to their newer versions: - coreKtx from 1.17.0 to 1.18.0 - activityCompose from 1.12.4 to 1.13.0 - composeBom from 2026.02.00 to 2026.03.00 --- gradle/libs.versions.toml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 71892dc..3424470 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,13 +1,13 @@ [versions] agp = "9.0.0" kotlin = "2.3.10" -coreKtx = "1.17.0" +coreKtx = "1.18.0" junit = "4.13.2" junitVersion = "1.3.0" espressoCore = "3.7.0" lifecycleRuntimeKtx = "2.10.0" -activityCompose = "1.12.4" -composeBom = "2026.02.00" +activityCompose = "1.13.0" +composeBom = "2026.03.00" appcompat = "1.7.1" material = "1.13.0" sqliteKtx = "2.6.2" From ccb0cb4fc6fbee6737d20a0e4996204e0aba637d Mon Sep 17 00:00:00 2001 From: LeandroLCD Date: Tue, 17 Mar 2026 12:59:04 -0300 Subject: [PATCH 33/40] feat(query): Add getOperators and clearOrderBy methods - Adds `getOperators()` to `QuerySelect` to retrieve a list of all `SQLOperator` instances from current operations. - Implements `clearOrderBy()` in both `QuerySelect` and `UnionQuery` to reset the `orderBy` field. --- .../main/java/com/blipblipcode/query/QuerySelect.kt | 10 ++++++++++ .../src/main/java/com/blipblipcode/query/UnionQuery.kt | 5 +++++ 2 files changed, 15 insertions(+) diff --git a/query/src/main/java/com/blipblipcode/query/QuerySelect.kt b/query/src/main/java/com/blipblipcode/query/QuerySelect.kt index 68008b9..2b915dc 100644 --- a/query/src/main/java/com/blipblipcode/query/QuerySelect.kt +++ b/query/src/main/java/com/blipblipcode/query/QuerySelect.kt @@ -134,6 +134,10 @@ class QuerySelect private constructor( } } + fun getOperators(): List> { + return operations.values.map { it.operator } + } + fun getOperations(): Map> { return buildMap { where?.let { put(it.first, it.second.operator) } @@ -215,6 +219,12 @@ class QuerySelect private constructor( return this } + fun clearOrderBy(): Queryable { + orderBy = null + return this + } + + /** * Adds a LIMIT clause to the query to limit the number of rows returned. * diff --git a/query/src/main/java/com/blipblipcode/query/UnionQuery.kt b/query/src/main/java/com/blipblipcode/query/UnionQuery.kt index 6532c64..5122e1a 100644 --- a/query/src/main/java/com/blipblipcode/query/UnionQuery.kt +++ b/query/src/main/java/com/blipblipcode/query/UnionQuery.kt @@ -95,6 +95,11 @@ class UnionQuery private constructor( fun getOrderBy(): OrderBy? { return orderBy } + + fun clearOrderBy(): Queryable { + orderBy = null + return this + } /** * Creates a new `QueryBuilder` initialized with the current state of this `UnionQuery`. * This allows for further modifications or additions to the existing union query. From ae210e1d4e38308fe3286920027bef89d1eb7cf5 Mon Sep 17 00:00:00 2001 From: LeandroLCD Date: Tue, 24 Mar 2026 18:02:47 -0300 Subject: [PATCH 34/40] feat(query): Add Collation support and transform capabilities to OrderBy - Introduces a `Collation` sealed interface to handle SQL `COLLATE` clauses (e.g., `NOCASE`, `BINARY`, `RTRIM`) and custom collation names. - Updates `OrderBy.Asc` and `OrderBy.Desc` to support `collation` and a `transform` function for wrapping columns in SQL functions like `REPLACE`. - Adds `CollationUtils.removeCharsTransform()` to easily generate chained `REPLACE` calls for sanitizing sort keys. - Refactors `UnionQuery` to wrap internal queries in a subquery `SELECT * FROM (...)` when using `ORDER BY`, preventing syntax errors with combined results. - Updates `QuerySelect.asSql` to allow predicate filtering on `OrderBy` and `Limit` operations. - Adds comprehensive tests for collation, character removal transformations, and `UnionQuery` sorting behavior. - Updates documentation in `README.md` with examples for new sorting features. --- README.md | 67 +++++++++++ .../com/blipblipcode/query/QuerySelect.kt | 13 ++- .../java/com/blipblipcode/query/UnionQuery.kt | 53 ++++----- .../blipblipcode/query/operator/Collation.kt | 83 ++++++++++++++ .../query/operator/CollationUtils.kt | 27 +++++ .../blipblipcode/query/operator/OrdenBy.kt | 65 +++++++---- .../blipblipcode/query/CollationUtilsTest.kt | 107 ++++++++++++++++++ .../com/blipblipcode/query/UnionQueryTest.kt | 72 ++++++++++-- 8 files changed, 412 insertions(+), 75 deletions(-) create mode 100644 query/src/main/java/com/blipblipcode/query/operator/Collation.kt create mode 100644 query/src/main/java/com/blipblipcode/query/operator/CollationUtils.kt create mode 100644 query/src/test/java/com/blipblipcode/query/CollationUtilsTest.kt diff --git a/README.md b/README.md index e775dd5..2daf1de 100644 --- a/README.md +++ b/README.md @@ -100,6 +100,73 @@ val selectQuery = QuerySelect.builder("users") selectQuery.limit(10, 5) val sqlString = selectQuery.asSql() + +### ⬆️ ORDER BY + +Para ordenar los resultados puedes utilizar el método `orderBy` con `OrderBy.Asc`, `OrderBy.Desc` o una lista mediante `OrderBy.Multiple`. + +`OrderBy.Asc` y `OrderBy.Desc` aceptan tres parámetros: + +| Parámetro | Tipo | Descripción | +|-------------|------------------------|-------------| +| `column` | `String` | Nombre de la columna. | +| `collation` | `Collation` | Cláusula COLLATE que se añade **al final** de la expresión (por defecto `Collation.NONE`). | +| `transform` | `(String) -> String` | Función que envuelve la columna en SQL arbitrario (REPLACE, LOWER, etc.). Se aplica **antes** del COLLATE. Por defecto es la identidad `{ it }`. | + +```kotlin +// ORDER BY simple asc +val q1 = QuerySelect.builder("users") + .where(SQLOperator.Equals("status", "active")) + .build() +q1.orderBy(OrderBy.Asc("name")) // -> ORDER BY name ASC + +// ORDER BY con múltiples columnas y direcciones mezcladas +q1.orderBy(OrderBy.Multiple(listOf( + OrderBy.Asc("department"), + OrderBy.Desc("created_at") +))) // -> ORDER BY department ASC, created_at DESC + +// ORDER BY usando Collation (ej: NOCASE o RTRIM) +q1.orderBy(OrderBy.Asc("name", collation = Collation.NOCASE)) +// -> ORDER BY name COLLATE NOCASE ASC + +q1.orderBy(OrderBy.Desc("name", collation = Collation.RTRIM)) +// -> ORDER BY RTRIM(name) DESC +``` + +#### 🔄 Transform + Collation + +Usa `transform` para aplicar funciones SQL sobre la columna (como `REPLACE`) y `collation` para +el `COLLATE`. El `transform` envuelve la columna **antes** de que se añada el COLLATE, +garantizando SQL correcto como: + +```sql +SELECT * FROM vehicle +ORDER BY REPLACE(identification, '-', '') COLLATE NOCASE ASC +``` + +> **Nota:** En SQLite, `LOWER()` no garantiza un ordenamiento case-insensitive correcto. +> Usa siempre `COLLATE NOCASE` para ignorar mayúsculas/minúsculas al ordenar. + +```kotlin +// Eliminar guiones e ignorar mayúsculas/minúsculas +val strip = "-".removeCharsTransform() // extensión de CollationUtils +q1.orderBy(OrderBy.Asc("identification", collation = Collation.NOCASE, transform = strip)) +// -> ORDER BY REPLACE(identification, '-', '') COLLATE NOCASE ASC + +// Solo COLLATE NOCASE (sin transform) +q1.orderBy(OrderBy.Asc("name", collation = Collation.NOCASE)) +// -> ORDER BY name COLLATE NOCASE ASC + +// Quitar espacios, guiones y puntos + COLLATE NOCASE +val stripChars = " -.".removeCharsTransform() +q1.orderBy(OrderBy.Asc("name", collation = Collation.NOCASE, transform = stripChars)) +// -> ORDER BY REPLACE(REPLACE(REPLACE(name, ' ', ''), '-', ''), '.', '') COLLATE NOCASE ASC + +// Combinar RTRIM + NOCASE con transform +val stripDash = "-".removeCharsTransform() +q1.orderBy(OrderBy.Asc("code", collation = Collation.RTRIM and Collation.NOCASE, transform = stripDash)) +// -> ORDER BY RTRIM(REPLACE(code, '-', '')) COLLATE NOCASE ASC ``` ### ➕ INSERT diff --git a/query/src/main/java/com/blipblipcode/query/QuerySelect.kt b/query/src/main/java/com/blipblipcode/query/QuerySelect.kt index 2b915dc..83b9765 100644 --- a/query/src/main/java/com/blipblipcode/query/QuerySelect.kt +++ b/query/src/main/java/com/blipblipcode/query/QuerySelect.kt @@ -187,22 +187,23 @@ class QuerySelect private constructor( */ override fun asSql(predicate: (SQLOperator<*>) -> Boolean): String { val fieldStr = if (fields.isEmpty()) "*" else fields.joinToString(", ") + val filteredWhere = where?.takeIf { predicate(it.second.operator) } val operationsStr = if (operations.isNotEmpty()) operations.values.filter { predicate(it.operator) } .joinToString(" ") { it.asString() } else "" return buildString { - if (where == null) { + if (filteredWhere == null) { append("SELECT $fieldStr FROM $table") } else { - append("SELECT $fieldStr FROM $table ${where?.second?.asString()} $operationsStr".trim()) + append("SELECT $fieldStr FROM $table ${filteredWhere.second.asString()} $operationsStr".trim()) } - if (orderBy != null) { + orderBy?.takeIf { predicate(it) }?.let { append(" ") - append(orderBy!!.asString()) + append(it.asString()) } - if (limit != null) { + limit?.takeIf { predicate(it) }?.let { append(" ") - append(limit!!.asString()) + append(it.asString()) } } } diff --git a/query/src/main/java/com/blipblipcode/query/UnionQuery.kt b/query/src/main/java/com/blipblipcode/query/UnionQuery.kt index 5122e1a..326d30d 100644 --- a/query/src/main/java/com/blipblipcode/query/UnionQuery.kt +++ b/query/src/main/java/com/blipblipcode/query/UnionQuery.kt @@ -36,16 +36,17 @@ class UnionQuery private constructor( */ override fun asSql(): String { require(queries.size >= 2) { "At least two queries are required for a UNION" } - val orders = queries.fold(mutableListOf()) { acc, query -> - query.getOrderBy()?.let { acc.add(it) } - query.orderBy(null) - acc - } - orders.addAll(orderBy?.let { listOf(it) } ?: emptyList()) + val orders = queries.mapNotNull { it.getOrderBy() }.toMutableList() + orderBy?.let { orders.add(it) } + val unionKeyword = if (useUnionAll) "UNION ALL" else "UNION" - + return buildString { - append(queries.joinToString("\n$unionKeyword\n") { it.asSql() }) + append("SELECT * FROM (") + appendLine() + append(queries.joinToString("\n$unionKeyword\n") { it.asSql { op -> op !is OrderBy } }) + appendLine() + append(")") if (orders.isNotEmpty()) { appendLine() append(OrderBy.Multiple(orders).asString()) @@ -60,16 +61,19 @@ class UnionQuery private constructor( */ override fun asSql(predicate: (SQLOperator<*>) -> Boolean): String { require(queries.size >= 2) { "At least two queries are required for a UNION" } - val orders = queries.fold(mutableListOf()) { acc, query -> - query.getOrderBy()?.let { acc.add(it) } - query.orderBy(null) - acc - } - orders.addAll(orderBy?.let { listOf(it) } ?: emptyList()) + val orders = queries.mapNotNull { it.getOrderBy()?.takeIf(predicate) }.toMutableList() + orderBy?.takeIf(predicate)?.let { orders.add(it) } + val unionKeyword = if (useUnionAll) "UNION ALL" else "UNION" return buildString { - append(queries.joinToString("\n$unionKeyword\n") { it.asSql(predicate) }) + append("SELECT * FROM (") + this.appendLine() + append(queries.joinToString("\n$unionKeyword\n") { query -> + query.asSql { op -> predicate(op) && op !is OrderBy } + }) + this.appendLine() + append(")") if (orders.isNotEmpty()) { appendLine() append(OrderBy.Multiple(orders).asString()) @@ -238,22 +242,3 @@ class UnionQuery private constructor( } } } -fun main() { - // Example usage: - val query1 = QuerySelect.builder("users") - .where(SQLOperator.Equals("age", 30)) - .and(SQLOperator.Equals("id", 30)).orderBy( - OrderBy.Asc("name")).build() - val query2 = QuerySelect.builder("admins") - .where(SQLOperator.Equals("edad", 30)) - .and(SQLOperator.Equals("ege", 30)) - .limit(10) - .orderBy(OrderBy.Desc("id")).build() - - val unionQuery = UnionQuery.builder(query1) - .addQuery(query2) - .unionAll() - .build() - - println(unionQuery.asSql()) -} \ No newline at end of file diff --git a/query/src/main/java/com/blipblipcode/query/operator/Collation.kt b/query/src/main/java/com/blipblipcode/query/operator/Collation.kt new file mode 100644 index 0000000..c526778 --- /dev/null +++ b/query/src/main/java/com/blipblipcode/query/operator/Collation.kt @@ -0,0 +1,83 @@ +package com.blipblipcode.query.operator + +/** + * Represents how a column expression should be collated when used in SQL ORDER BY. + * Implementations append a COLLATE clause (e.g. COLLATE NOCASE) or wrap the + * expression in a recognised SQL collation function (e.g. RTRIM(column)). + * + * Arbitrary transformations such as REPLACE or LOWER should be passed via the + * `transform` parameter of [OrderBy.Asc] / [OrderBy.Desc] instead. + */ +sealed interface Collation { + /** Apply this collation to the given SQL expression. */ + fun apply(expression: String): String + + /** + * Indicates whether this Collation produces a COLLATE suffix (e.g. " COLLATE NOCASE"). + * Wrapper-style collations (like RTRIM) return false so they are + * applied before any COLLATE suffixes. Default is false. + */ + fun isCollateSuffix(): Boolean = false + + object NONE : Collation { + override fun apply(expression: String): String = expression + override fun toString(): String = "NONE" + } + + object NOCASE : Collation { + override fun apply(expression: String): String = "$expression COLLATE NOCASE" + override fun isCollateSuffix(): Boolean = true + override fun toString(): String = "NOCASE" + } + + object BINARY : Collation { + override fun apply(expression: String): String = "$expression COLLATE BINARY" + override fun isCollateSuffix(): Boolean = true + override fun toString(): String = "BINARY" + } + + object RTRIM : Collation { + override fun apply(expression: String): String = "RTRIM($expression)" + override fun toString(): String = "RTRIM" + } + + data class CustomCollate(val collateName: String) : Collation { + override fun apply(expression: String): String = "$expression COLLATE $collateName" + override fun isCollateSuffix(): Boolean = true + override fun toString(): String = collateName + } + + /** + * Compose multiple Collation instances and apply them sequentially. + * Wrapper-style collations (e.g. RTRIM) are always applied before + * COLLATE-suffix collations (e.g. NOCASE, BINARY) regardless of + * the order they appear in [parts]. + * + * Example: Composite(listOf(Collation.RTRIM, Collation.NOCASE)) + * produces RTRIM(expr) COLLATE NOCASE + */ + data class Composite(val parts: List) : Collation { + constructor(vararg parts: Collation) : this(parts.toList()) + override fun apply(expression: String): String { + val (wrappers, suffixes) = parts.partition { !it.isCollateSuffix() } + val afterWrappers = wrappers.fold(expression) { acc, c -> c.apply(acc) } + return suffixes.fold(afterWrappers) { acc, c -> c.apply(acc) } + } + override fun toString(): String = parts.joinToString(" -> ") + } +} + +/** Infix helper to compose two Collations into a Composite (left-to-right). */ +infix fun Collation.and(other: Collation): Collation = when (this) { + is Collation.Composite -> when (other) { + is Collation.Composite -> Collation.Composite(this.parts + other.parts) + else -> Collation.Composite(this.parts + other) + } + else -> when (other) { + is Collation.Composite -> Collation.Composite(listOf(this) + other.parts) + else -> Collation.Composite(listOf(this, other)) + } +} + +/** Convenience factory for building composites. */ +fun collationsOf(vararg parts: Collation): Collation = Collation.Composite(parts.toList()) diff --git a/query/src/main/java/com/blipblipcode/query/operator/CollationUtils.kt b/query/src/main/java/com/blipblipcode/query/operator/CollationUtils.kt new file mode 100644 index 0000000..a5a70eb --- /dev/null +++ b/query/src/main/java/com/blipblipcode/query/operator/CollationUtils.kt @@ -0,0 +1,27 @@ +package com.blipblipcode.query.operator + +/** + * Crea una función de transformación SQL que elimina (con REPLACE encadenado) + * los caracteres presentes en [this]. + * Cada carácter se eliminará mediante REPLACE(..., 'c', ''). + * Si [this] está vacío, devuelve la identidad (no transforma nada). + * + * Ejemplo de uso: + * ```kotlin + * val strip = "-. ".removeCharsTransform() + * query.orderBy(OrderBy.Asc("name", collation = Collation.NOCASE, transform = strip)) + * // -> ORDER BY REPLACE(REPLACE(REPLACE(name, '-', ''), '.', ''), ' ', '') COLLATE NOCASE ASC + * ``` + */ +fun String.removeCharsTransform(): (String) -> String { + if (isEmpty()) return { it } + val chars = this + return { expr -> + var result = expr + for (ch in chars) { + val escaped = if (ch == '\'') "''" else ch.toString() + result = "REPLACE($result, '$escaped', '')" + } + result + } +} diff --git a/query/src/main/java/com/blipblipcode/query/operator/OrdenBy.kt b/query/src/main/java/com/blipblipcode/query/operator/OrdenBy.kt index 2a6eecd..6d6726c 100644 --- a/query/src/main/java/com/blipblipcode/query/operator/OrdenBy.kt +++ b/query/src/main/java/com/blipblipcode/query/operator/OrdenBy.kt @@ -1,61 +1,80 @@ package com.blipblipcode.query.operator -sealed interface OrderBy:SQLOperator { +sealed interface OrderBy : SQLOperator { override val column: String override val symbol: String override val value: String override val caseConversion: CaseConversion + get() = CaseConversion.NONE + + val transform: (String) -> String + val collation: Collation override fun asString(): String fun asSqlClause(): String { return when (this) { - is Asc , is Desc -> "${caseConversion.asSqlFunction(column)} $value" + is Asc, is Desc -> collation.apply(transform(column)) + " " + value is Multiple -> orders.joinToString(", ") { it.asSqlClause() } } } + fun clone(vararg params: Any?): OrderBy - data class Asc(override val column: String) : OrderBy{ + data class Asc( + override val column: String, + override val collation: Collation = Collation.NONE, + override val transform: (String) -> String = { it } + ) : OrderBy { override val symbol: String = "ORDER BY" override val value: String = "ASC" - override val caseConversion: CaseConversion = CaseConversion.NONE + override fun asString(): String { - return "$symbol ${caseConversion.asSqlFunction(column)} $value" + val expr = collation.apply(transform(column)) + return "$symbol $expr $value" } override fun clone(vararg params: Any?): OrderBy { - return this.copy(column = params[0] as String) + val newColumn = params.getOrNull(0) as? String ?: column + val newCollation = params.getOrNull(1) as? Collation ?: collation + @Suppress("UNCHECKED_CAST") + val newTransform = params.getOrNull(2) as? (String) -> String ?: transform + return this.copy(column = newColumn, transform = newTransform, collation = newCollation) } - override fun toString(): String { - return asString() - } + override fun toString(): String = asString() } - data class Desc(override val column: String) : OrderBy{ + + data class Desc( + override val column: String, + override val collation: Collation = Collation.NONE, + override val transform: (String) -> String = { it } + ) : OrderBy { override val symbol: String = "ORDER BY" override val value: String = "DESC" - override val caseConversion: CaseConversion = CaseConversion.NONE override fun asString(): String { - return "$symbol ${caseConversion.asSqlFunction(column)} $value" + val expr = collation.apply(transform(column)) + return "$symbol $expr $value" } override fun clone(vararg params: Any?): OrderBy { - return this.copy(column = params[0] as String) + val newColumn = params.getOrNull(0) as? String ?: column + val newCollation = params.getOrNull(1) as? Collation ?: collation + @Suppress("UNCHECKED_CAST") + val newTransform = params.getOrNull(2) as? (String) -> String ?: transform + return this.copy(column = newColumn, transform = newTransform, collation = newCollation) } - override fun toString(): String { - return asString() - } + override fun toString(): String = asString() } - data class Multiple(val orders: List) : OrderBy{ + data class Multiple(val orders: List) : OrderBy { override val column: String get() = orders.joinToString(", ") { it.column } override val symbol: String = "ORDER BY" override val value: String = orders.joinToString(", ") { it.value } - override val caseConversion: CaseConversion = CaseConversion.NONE - + override val collation: Collation = Collation.NONE + override val transform: (String) -> String = { it } override fun asString(): String { return "ORDER BY ${asSqlClause()}" @@ -72,9 +91,7 @@ sealed interface OrderBy:SQLOperator { throw IllegalArgumentException("The parameters provided for cloning are not of the List type.") } - override fun toString(): String { - return "ORDER BY ${asSqlClause()}" - } + override fun toString(): String = "ORDER BY ${asSqlClause()}" override fun equals(other: Any?): Boolean { if (this === other) return true @@ -82,9 +99,7 @@ sealed interface OrderBy:SQLOperator { return orders == other.orders } - override fun hashCode(): Int { - return orders.hashCode() - } + override fun hashCode(): Int = orders.hashCode() } } \ No newline at end of file diff --git a/query/src/test/java/com/blipblipcode/query/CollationUtilsTest.kt b/query/src/test/java/com/blipblipcode/query/CollationUtilsTest.kt new file mode 100644 index 0000000..0ccb6e0 --- /dev/null +++ b/query/src/test/java/com/blipblipcode/query/CollationUtilsTest.kt @@ -0,0 +1,107 @@ +package com.blipblipcode.query + +import com.blipblipcode.query.operator.Collation +import com.blipblipcode.query.operator.and +import com.blipblipcode.query.operator.removeCharsTransform +import com.blipblipcode.query.operator.OrderBy +import com.blipblipcode.query.operator.SQLOperator +import org.junit.Assert.assertEquals +import org.junit.Test + +class CollationUtilsTest { + + @Test + fun `removeCharsTransform builds REPLACE chain with COLLATE NOCASE`() { + val q = QuerySelect.builder("users") + .where(SQLOperator.Equals("status", "active")) + .build() + + // eliminar espacios, guiones y puntos; ignorar mayúsculas con COLLATE NOCASE + val strip = " -.".removeCharsTransform() + + q.orderBy(OrderBy.Asc("name", collation = Collation.NOCASE, transform = strip)) + + val expectedSql = "SELECT * FROM users WHERE status = 'active' ORDER BY REPLACE(REPLACE(REPLACE(name, ' ', ''), '-', ''), '.', '') COLLATE NOCASE ASC" + assertEquals(expectedSql, q.asSql().trim()) + } + + @Test + fun `REPLACE with COLLATE NOCASE produces correct SQL for vehicle identification`() { + val q = QuerySelect.builder("vehicle") + .where(SQLOperator.Equals("active", true)) + .build() + + // Eliminar guiones y aplicar COLLATE NOCASE + val strip = "-".removeCharsTransform() + q.orderBy(OrderBy.Asc("identification", collation = Collation.NOCASE, transform = strip)) + + val expectedSql = "SELECT * FROM vehicle WHERE active = true ORDER BY REPLACE(identification, '-', '') COLLATE NOCASE ASC" + assertEquals(expectedSql, q.asSql().trim()) + } + + @Test + fun `REPLACE with COLLATE BINARY produces correct SQL`() { + val q = QuerySelect.builder("products") + .where(SQLOperator.Equals("status", "available")) + .build() + + val strip = " ".removeCharsTransform() + q.orderBy(OrderBy.Desc("code", collation = Collation.BINARY, transform = strip)) + + val expectedSql = "SELECT * FROM products WHERE status = 'available' ORDER BY REPLACE(code, ' ', '') COLLATE BINARY DESC" + assertEquals(expectedSql, q.asSql().trim()) + } + + @Test + fun `COLLATE NOCASE alone is sufficient for case-insensitive ordering`() { + val q = QuerySelect.builder("users") + .where(SQLOperator.Equals("status", "active")) + .build() + + q.orderBy(OrderBy.Asc("name", collation = Collation.NOCASE)) + + val expectedSql = "SELECT * FROM users WHERE status = 'active' ORDER BY name COLLATE NOCASE ASC" + assertEquals(expectedSql, q.asSql().trim()) + } + + @Test + fun `empty string removeCharsTransform returns identity`() { + val transform = "".removeCharsTransform() + assertEquals("col", transform("col")) + } + + @Test + fun `removeCharsTransform escapes single quotes`() { + val transform = "'".removeCharsTransform() + assertEquals("REPLACE(name, '''', '')", transform("name")) + } + + @Test + fun `RTRIM and NOCASE composite collation with transform`() { + val q = QuerySelect.builder("items") + .where(SQLOperator.Equals("type", "A")) + .build() + + val strip = "-".removeCharsTransform() + q.orderBy(OrderBy.Asc("code", collation = Collation.RTRIM and Collation.NOCASE, transform = strip)) + + val expectedSql = "SELECT * FROM items WHERE type = 'A' ORDER BY RTRIM(REPLACE(code, '-', '')) COLLATE NOCASE ASC" + assertEquals(expectedSql, q.asSql().trim()) + } + + @Test + fun `Multiple OrderBy with different transforms and collations`() { + val q = QuerySelect.builder("vehicle") + .where(SQLOperator.Equals("active", true)) + .build() + + val stripDash = "-".removeCharsTransform() + q.orderBy(OrderBy.Multiple(listOf( + OrderBy.Asc("identification", collation = Collation.NOCASE, transform = stripDash), + OrderBy.Desc("name") + ))) + + val expectedSql = "SELECT * FROM vehicle WHERE active = true ORDER BY REPLACE(identification, '-', '') COLLATE NOCASE ASC, name DESC" + assertEquals(expectedSql, q.asSql().trim()) + } +} diff --git a/query/src/test/java/com/blipblipcode/query/UnionQueryTest.kt b/query/src/test/java/com/blipblipcode/query/UnionQueryTest.kt index 4ac2c18..77aeafc 100644 --- a/query/src/test/java/com/blipblipcode/query/UnionQueryTest.kt +++ b/query/src/test/java/com/blipblipcode/query/UnionQueryTest.kt @@ -3,6 +3,7 @@ package com.blipblipcode.query import com.blipblipcode.query.operator.OrderBy import com.blipblipcode.query.operator.SQLOperator import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull import org.junit.Assert.assertThrows import org.junit.Test @@ -15,28 +16,28 @@ class UnionQueryTest { @Test fun `orderBy with single column`() { val unionQuery = UnionQuery.builder(query1).addQuery(query2).build().orderBy(OrderBy.Asc("name")) - val expectedSql = "${query1.asSql()}\nUNION\n${query2.asSql()}\nORDER BY name ASC" + val expectedSql = "SELECT * FROM (\n${query1.asSql()}\nUNION\n${query2.asSql()}\n)\nORDER BY name ASC" assertEquals(expectedSql, unionQuery.asSql()) } @Test fun `orderBy with multiple columns`() { val unionQuery = UnionQuery.builder(query1).addQuery(query2).build().orderBy(OrderBy.Asc("name")) - val expectedSql = "${query1.asSql()}\nUNION\n${query2.asSql()}\nORDER BY name ASC" + val expectedSql = "SELECT * FROM (\n${query1.asSql()}\nUNION\n${query2.asSql()}\n)\nORDER BY name ASC" assertEquals(expectedSql, unionQuery.asSql()) } @Test fun `orderBy with different sort directions`() { val unionQuery = UnionQuery.builder(query1).addQuery(query2).build().orderBy(OrderBy.Desc("name")) - val expectedSql = "${query1.asSql()}\nUNION\n${query2.asSql()}\nORDER BY name DESC" + val expectedSql = "SELECT * FROM (\n${query1.asSql()}\nUNION\n${query2.asSql()}\n)\nORDER BY name DESC" assertEquals(expectedSql, unionQuery.asSql()) } @Test fun `orderBy overwriting previous clause`() { val unionQuery = UnionQuery.builder(query1).addQuery(query2).build().orderBy(OrderBy.Desc("age")) - val expectedSql = "${query1.asSql()}\nUNION\n${query2.asSql()}\nORDER BY age DESC" + val expectedSql = "SELECT * FROM (\n${query1.asSql()}\nUNION\n${query2.asSql()}\n)\nORDER BY age DESC" assertEquals(expectedSql, unionQuery.asSql()) } @@ -50,42 +51,42 @@ class UnionQueryTest { @Test fun `asSql with two queries for UNION`() { val unionQuery = UnionQuery.builder(query1).addQuery( query2).build() - val expectedSql = "${query1.asSql()}\nUNION\n${query2.asSql()}" + val expectedSql = "SELECT * FROM (\n${query1.asSql()}\nUNION\n${query2.asSql()}\n)" assertEquals(expectedSql, unionQuery.asSql()) } @Test fun `asSql with two queries for UNION ALL`() { val unionQuery = UnionQuery.builder(query1).addQuery(query2).unionAll().build() - val expectedSql = "${query1.asSql()}\nUNION ALL\n${query2.asSql()}" + val expectedSql = "SELECT * FROM (\n${query1.asSql()}\nUNION ALL\n${query2.asSql()}\n)" assertEquals(expectedSql, unionQuery.asSql()) } @Test fun `asSql with multiple queries for UNION`() { val unionQuery = UnionQuery.builder(query1).addQuery(query2).addQuery(query3).build() - val expectedSql = "${query1.asSql()}\nUNION\n${query2.asSql()}\nUNION\n${query3.asSql()}" + val expectedSql = "SELECT * FROM (\n${query1.asSql()}\nUNION\n${query2.asSql()}\nUNION\n${query3.asSql()}\n)" assertEquals(expectedSql, unionQuery.asSql()) } @Test fun `asSql with multiple queries for UNION ALL`() { val unionQuery = UnionQuery.builder(query1).addQuery( query2).addQuery(query3).unionAll().build() - val expectedSql = "${query1.asSql()}\nUNION ALL\n${query2.asSql()}\nUNION ALL\n${query3.asSql()}" + val expectedSql = "SELECT * FROM (\n${query1.asSql()}\nUNION ALL\n${query2.asSql()}\nUNION ALL\n${query3.asSql()}\n)" assertEquals(expectedSql, unionQuery.asSql()) } @Test fun `asSql for UNION with an orderBy clause`() { val unionQuery = UnionQuery.builder(query1).addQuery(query2).build().orderBy(OrderBy.Desc("name")) - val expectedSql = "${query1.asSql()}\nUNION\n${query2.asSql()}\nORDER BY name DESC" + val expectedSql = "SELECT * FROM (\n${query1.asSql()}\nUNION\n${query2.asSql()}\n)\nORDER BY name DESC" assertEquals(expectedSql, unionQuery.asSql()) } @Test fun `asSql for UNION ALL with an orderBy clause`() { val unionQuery = UnionQuery.builder(query1).addQuery(query2).unionAll().build().orderBy(OrderBy.Asc("name")) - val expectedSql = "${query1.asSql()}\nUNION ALL\n${query2.asSql()}\nORDER BY name ASC" + val expectedSql = "SELECT * FROM (\n${query1.asSql()}\nUNION ALL\n${query2.asSql()}\n)\nORDER BY name ASC" assertEquals(expectedSql, unionQuery.asSql()) } @@ -111,4 +112,55 @@ class UnionQueryTest { val unionQuery = UnionQuery.builder(query1).addQuery(query2).build() assertEquals(queries, unionQuery.queries) } + + @Test + fun `asSql preserve internal queries orderBy and combines it in UnionQuery`() { + val q1 = QuerySelect.builder("table1") + .where(SQLOperator.Equals("id", 1)) + .orderBy(OrderBy.Asc("name")) + .build() + val q2 = QuerySelect.builder("table2") + .where(SQLOperator.Equals("id", 2)) + .orderBy(OrderBy.Desc("date")) + .build() + + val unionQuery = UnionQuery.builder(q1) + .addQuery(q2) + .build() + + val sql = unionQuery.asSql() + + // El SQL resultante de UnionQuery debe tener los ORDER BY combinados al final + // y las queries internas NO deben tener el ORDER BY (regla SQL para UNION) + val expectedSql = "SELECT * FROM (\nSELECT * FROM table1 WHERE id = 1\nUNION\nSELECT * FROM table2 WHERE id = 2\n)\nORDER BY name ASC, date DESC" + assertEquals(expectedSql, sql) + + // Verificamos que las queries originales NO fueron modificadas (no se les puso el orderBy en null) + assertNotNull(q1.getOrderBy()) + assertNotNull(q2.getOrderBy()) + assertEquals("name", q1.getOrderBy()?.column) + assertEquals("date", q2.getOrderBy()?.column) + } + + @Test + fun `asSql with UnionQuery level orderBy and internal queries orderBy`() { + val q1 = QuerySelect.builder("table1") + .where(SQLOperator.Equals("id", 1)) + .orderBy(OrderBy.Asc("name")) + .build() + val q2 = QuerySelect.builder("table2") + .where(SQLOperator.Equals("id", 2)) + .build() + + val unionQuery = UnionQuery.builder(q1) + .addQuery(q2) + .build() + .orderBy(OrderBy.Desc("id")) + + val sql = unionQuery.asSql() + + // El SQL debe contener el orderBy de q1 y el orderBy de unionQuery al final + val expectedSql = "SELECT * FROM (\nSELECT * FROM table1 WHERE id = 1\nUNION\nSELECT * FROM table2 WHERE id = 2\n)\nORDER BY name ASC, id DESC" + assertEquals(expectedSql, sql) + } } \ No newline at end of file From dbc8dd3d49e3e19ff5ffec48845dc9f91c2ecc0f Mon Sep 17 00:00:00 2001 From: LeandroLCD Date: Wed, 27 May 2026 15:03:06 -0400 Subject: [PATCH 35/40] feat(query): Add limit management and refine OrderBy behavior in UnionQuery - Introduces `limit()` and `clearLimit()` to `UnionQuery` to apply or remove limit constraints across all internal queries. - Refactors `UnionQuery.asSql()` to ensure that a top-level `orderBy` takes precedence, ignoring internal query sort orders when set. - Adds `clearLimit()` to `QuerySelect` to allow resetting the `LIMIT` clause. - Implements `QuerySelectBuilder` test utility for more fluent test case construction. - Adds comprehensive unit tests for `limit` propagation and `orderBy` precedence within `UnionQuery`. --- .../com/blipblipcode/query/QuerySelect.kt | 9 + .../java/com/blipblipcode/query/UnionQuery.kt | 84 +++++++- .../com/blipblipcode/query/UnionQueryTest.kt | 181 +++++++++++++++++- .../query/builder/QuerySelectBuilder.kt | 38 ++++ 4 files changed, 301 insertions(+), 11 deletions(-) create mode 100644 query/src/test/java/com/blipblipcode/query/builder/QuerySelectBuilder.kt diff --git a/query/src/main/java/com/blipblipcode/query/QuerySelect.kt b/query/src/main/java/com/blipblipcode/query/QuerySelect.kt index 83b9765..450e564 100644 --- a/query/src/main/java/com/blipblipcode/query/QuerySelect.kt +++ b/query/src/main/java/com/blipblipcode/query/QuerySelect.kt @@ -257,6 +257,15 @@ class QuerySelect private constructor( return this.limit } + /** + * Clears the LIMIT clause from the query. + * @return The current `QuerySelect` instance for chaining. + */ + fun clearLimit(): QuerySelect { + limit = null + return this + } + /** * A builder for creating `QuerySelect` instances. diff --git a/query/src/main/java/com/blipblipcode/query/UnionQuery.kt b/query/src/main/java/com/blipblipcode/query/UnionQuery.kt index 326d30d..c238c6d 100644 --- a/query/src/main/java/com/blipblipcode/query/UnionQuery.kt +++ b/query/src/main/java/com/blipblipcode/query/UnionQuery.kt @@ -1,5 +1,6 @@ package com.blipblipcode.query +import com.blipblipcode.query.operator.Limit import com.blipblipcode.query.operator.LogicalOperation import com.blipblipcode.query.operator.OrderBy import com.blipblipcode.query.operator.SQLOperator @@ -31,13 +32,22 @@ class UnionQuery private constructor( /** * Generates the SQL string for the UNION statement. + * + * If an ORDER BY has been explicitly set on this `UnionQuery` (via [orderBy]), + * it will be used and any ORDER BY clauses from the individual queries will be ignored. + * If no ORDER BY has been set on this `UnionQuery`, the ORDER BY clauses from the + * individual queries will be collected and combined at the end of the UNION. + * * @return The complete UNION SQL query as a string. * @throws IllegalArgumentException if less than two queries are provided. */ override fun asSql(): String { require(queries.size >= 2) { "At least two queries are required for a UNION" } - val orders = queries.mapNotNull { it.getOrderBy() }.toMutableList() - orderBy?.let { orders.add(it) } + val orders = if (orderBy != null) { + listOf(orderBy!!) + } else { + queries.mapNotNull { it.getOrderBy() } + } val unionKeyword = if (useUnionAll) "UNION ALL" else "UNION" @@ -55,14 +65,24 @@ class UnionQuery private constructor( } /** * Generates the SQL string for the UNION statement. + * + * If an ORDER BY has been explicitly set on this `UnionQuery` (via [orderBy]), + * it will be used (if it passes the predicate) and any ORDER BY clauses from the + * individual queries will be ignored. + * If no ORDER BY has been set on this `UnionQuery`, the ORDER BY clauses from the + * individual queries that pass the predicate will be collected and combined at the end. + * * @param predicate The predicate to filter the operators. * @return The complete UNION SQL query as a string. * @throws IllegalArgumentException if less than two queries are provided. */ override fun asSql(predicate: (SQLOperator<*>) -> Boolean): String { require(queries.size >= 2) { "At least two queries are required for a UNION" } - val orders = queries.mapNotNull { it.getOrderBy()?.takeIf(predicate) }.toMutableList() - orderBy?.takeIf(predicate)?.let { orders.add(it) } + val orders = if (orderBy != null) { + listOfNotNull(orderBy?.takeIf(predicate)) + } else { + queries.mapNotNull { it.getOrderBy()?.takeIf(predicate) } + } val unionKeyword = if (useUnionAll) "UNION ALL" else "UNION" @@ -82,11 +102,14 @@ class UnionQuery private constructor( } /** - * Appends an ORDER BY clause to the entire UNION query. - * Note that in most SQL dialects, an ORDER BY clause can only be applied to the final result of a UNION, not to individual `SELECT` statements within it. + * Sets an ORDER BY clause for the entire UNION query. + * When set, this ORDER BY takes precedence and any ORDER BY clauses + * defined in the individual queries will be ignored. + * If not set (null), the ORDER BY clauses from the individual queries + * will be collected and combined at the end of the UNION statement. * - * @param operator A vararg of `OrderExpression` objects specifying the columns and direction for sorting. - * @return A new `QuerySelect` instance representing the UNION query with the added ORDER BY clause. + * @param operator The `OrderBy` object specifying the column and direction for sorting. + * @return This `UnionQuery` instance for chaining. */ fun orderBy(operator: OrderBy): Queryable { orderBy = operator @@ -104,6 +127,51 @@ class UnionQuery private constructor( orderBy = null return this } + + /** + * Applies a LIMIT clause to all internal queries. + * + * @param count The maximum number of rows to return per query. + * @param offset The number of rows to skip before returning results (optional). + * @param override If true, replaces any existing LIMIT in each query. + * If false (default), only applies the LIMIT to queries that do not already have one. + * @return This `UnionQuery` instance for chaining. + */ + fun limit(count: Int, offset: Int? = null, override: Boolean = false): UnionQuery { + queries.forEach { query -> + if (override || query.getLimit() == null) { + query.limit(count, offset) + } + } + return this + } + + /** + * Applies a LIMIT clause to all internal queries using a [Limit] object. + * + * @param limitOperator The [Limit] object specifying the limit parameters. + * @param override If true, replaces any existing LIMIT in each query. + * If false (default), only applies the LIMIT to queries that do not already have one. + * @return This `UnionQuery` instance for chaining. + */ + fun limit(limitOperator: Limit, override: Boolean = false): UnionQuery { + queries.forEach { query -> + if (override || query.getLimit() == null) { + query.limit(limitOperator) + } + } + return this + } + + /** + * Removes the LIMIT clause from all internal queries. + * + * @return This `UnionQuery` instance for chaining. + */ + fun clearLimit(): UnionQuery { + queries.forEach { it.clearLimit() } + return this + } /** * Creates a new `QueryBuilder` initialized with the current state of this `UnionQuery`. * This allows for further modifications or additions to the existing union query. diff --git a/query/src/test/java/com/blipblipcode/query/UnionQueryTest.kt b/query/src/test/java/com/blipblipcode/query/UnionQueryTest.kt index 77aeafc..eb909a5 100644 --- a/query/src/test/java/com/blipblipcode/query/UnionQueryTest.kt +++ b/query/src/test/java/com/blipblipcode/query/UnionQueryTest.kt @@ -1,9 +1,12 @@ package com.blipblipcode.query +import com.blipblipcode.query.builder.querySelect +import com.blipblipcode.query.operator.Limit import com.blipblipcode.query.operator.OrderBy import com.blipblipcode.query.operator.SQLOperator import org.junit.Assert.assertEquals import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull import org.junit.Assert.assertThrows import org.junit.Test @@ -143,7 +146,7 @@ class UnionQueryTest { } @Test - fun `asSql with UnionQuery level orderBy and internal queries orderBy`() { + fun `asSql with UnionQuery level orderBy ignores internal queries orderBy`() { val q1 = QuerySelect.builder("table1") .where(SQLOperator.Equals("id", 1)) .orderBy(OrderBy.Asc("name")) @@ -159,8 +162,180 @@ class UnionQueryTest { val sql = unionQuery.asSql() - // El SQL debe contener el orderBy de q1 y el orderBy de unionQuery al final - val expectedSql = "SELECT * FROM (\nSELECT * FROM table1 WHERE id = 1\nUNION\nSELECT * FROM table2 WHERE id = 2\n)\nORDER BY name ASC, id DESC" + // Cuando se define un orderBy a nivel de UnionQuery, se ignoran los orderBy de las queries internas + val expectedSql = "SELECT * FROM (\nSELECT * FROM table1 WHERE id = 1\nUNION\nSELECT * FROM table2 WHERE id = 2\n)\nORDER BY id DESC" assertEquals(expectedSql, sql) } + + // ---- limit() ---- + + @Test + fun should_apply_limit_to_all_queries_when_no_query_has_limit_in_limit() { + //GIVEN + val q1 = querySelect { withTable("table1") } + val q2 = querySelect { withTable("table2") } + val unionQuery = UnionQuery.builder(q1).addQuery(q2).build() + + //WHEN + unionQuery.limit(10) + + //THEN + assertEquals(10, q1.getLimit()?.count) + assertEquals(10, q2.getLimit()?.count) + } + + @Test + fun should_apply_limit_with_offset_to_all_queries_when_no_query_has_limit_in_limit() { + //GIVEN + val q1 = querySelect { withTable("table1") } + val q2 = querySelect { withTable("table2") } + val unionQuery = UnionQuery.builder(q1).addQuery(q2).build() + + //WHEN + unionQuery.limit(10, 5) + + //THEN + assertEquals(10, q1.getLimit()?.count) + assertEquals(5, q1.getLimit()?.offset) + assertEquals(10, q2.getLimit()?.count) + assertEquals(5, q2.getLimit()?.offset) + } + + @Test + fun should_not_replace_existing_limit_when_override_is_false_in_limit() { + //GIVEN + val q1 = querySelect { withTable("table1"); withLimit(3) } + val q2 = querySelect { withTable("table2") } + val unionQuery = UnionQuery.builder(q1).addQuery(q2).build() + + //WHEN + unionQuery.limit(10, override = false) + + //THEN + assertEquals(3, q1.getLimit()?.count) + assertEquals(10, q2.getLimit()?.count) + } + + @Test + fun should_replace_existing_limit_when_override_is_true_in_limit() { + //GIVEN + val q1 = querySelect { withTable("table1"); withLimit(3) } + val q2 = querySelect { withTable("table2") } + val unionQuery = UnionQuery.builder(q1).addQuery(q2).build() + + //WHEN + unionQuery.limit(10, override = true) + + //THEN + assertEquals(10, q1.getLimit()?.count) + assertEquals(10, q2.getLimit()?.count) + } + + @Test + fun should_apply_limit_operator_to_all_queries_when_no_query_has_limit_in_limit() { + //GIVEN + val q1 = querySelect { withTable("table1") } + val q2 = querySelect { withTable("table2") } + val limitOperator = Limit(20, 5) + val unionQuery = UnionQuery.builder(q1).addQuery(q2).build() + + //WHEN + unionQuery.limit(limitOperator) + + //THEN + assertEquals(limitOperator, q1.getLimit()) + assertEquals(limitOperator, q2.getLimit()) + } + + @Test + fun should_not_replace_existing_limit_when_using_limit_operator_and_override_is_false_in_limit() { + //GIVEN + val existingLimit = Limit(3) + val q1 = querySelect { withTable("table1"); withLimit(existingLimit) } + val q2 = querySelect { withTable("table2") } + val unionQuery = UnionQuery.builder(q1).addQuery(q2).build() + + //WHEN + unionQuery.limit(Limit(20), override = false) + + //THEN + assertEquals(existingLimit, q1.getLimit()) + assertEquals(20, q2.getLimit()?.count) + } + + @Test + fun should_replace_existing_limit_when_using_limit_operator_and_override_is_true_in_limit() { + //GIVEN + val q1 = querySelect { withTable("table1"); withLimit(3) } + val q2 = querySelect { withTable("table2"); withLimit(5) } + val newLimit = Limit(50) + val unionQuery = UnionQuery.builder(q1).addQuery(q2).build() + + //WHEN + unionQuery.limit(newLimit, override = true) + + //THEN + assertEquals(newLimit, q1.getLimit()) + assertEquals(newLimit, q2.getLimit()) + } + + @Test + fun should_return_same_instance_when_calling_limit_in_limit() { + //GIVEN + val q1 = querySelect { withTable("table1") } + val q2 = querySelect { withTable("table2") } + val unionQuery = UnionQuery.builder(q1).addQuery(q2).build() + + //WHEN + val result = unionQuery.limit(10) + + //THEN + assertEquals(unionQuery, result) + } + + // ---- clearLimit() ---- + + @Test + fun should_remove_limit_from_all_queries_in_clearLimit() { + //GIVEN + val q1 = querySelect { withTable("table1"); withLimit(10) } + val q2 = querySelect { withTable("table2"); withLimit(5) } + val unionQuery = UnionQuery.builder(q1).addQuery(q2).build() + + //WHEN + unionQuery.clearLimit() + + //THEN + assertNull(q1.getLimit()) + assertNull(q2.getLimit()) + } + + @Test + fun should_not_fail_when_queries_have_no_limit_in_clearLimit() { + //GIVEN + val q1 = querySelect { withTable("table1") } + val q2 = querySelect { withTable("table2") } + val unionQuery = UnionQuery.builder(q1).addQuery(q2).build() + + //WHEN + unionQuery.clearLimit() + + //THEN + assertNull(q1.getLimit()) + assertNull(q2.getLimit()) + } + + @Test + fun should_return_same_instance_in_clearLimit() { + //GIVEN + val q1 = querySelect { withTable("table1"); withLimit(10) } + val q2 = querySelect { withTable("table2") } + val unionQuery = UnionQuery.builder(q1).addQuery(q2).build() + + //WHEN + val result = unionQuery.clearLimit() + + //THEN + assertEquals(unionQuery, result) + } } \ No newline at end of file diff --git a/query/src/test/java/com/blipblipcode/query/builder/QuerySelectBuilder.kt b/query/src/test/java/com/blipblipcode/query/builder/QuerySelectBuilder.kt new file mode 100644 index 0000000..99e6daa --- /dev/null +++ b/query/src/test/java/com/blipblipcode/query/builder/QuerySelectBuilder.kt @@ -0,0 +1,38 @@ +package com.blipblipcode.query.builder + +import com.blipblipcode.query.QuerySelect +import com.blipblipcode.query.operator.Limit +import com.blipblipcode.query.operator.OrderBy +import com.blipblipcode.query.operator.SQLOperator + +class QuerySelectBuilder { + private var table: String = "default_table" + private var whereKey: String = "id" + private var whereOperator: SQLOperator<*> = SQLOperator.Equals("id", 1) + private var limit: Limit? = null + private var orderBy: OrderBy? = null + + fun withTable(table: String) = apply { this.table = table } + fun withWhere(key: String, operator: SQLOperator<*>) = apply { + this.whereKey = key + this.whereOperator = operator + } + fun withLimit(count: Int, offset: Int? = null) = apply { this.limit = Limit(count, offset) } + fun withLimit(limit: Limit) = apply { this.limit = limit } + fun withOrderBy(orderBy: OrderBy) = apply { this.orderBy = orderBy } + + fun build(): QuerySelect { + val query = QuerySelect.builder(table) + .where(whereOperator) + .also { builder -> + orderBy?.let { builder.orderBy(it) } + limit?.let { builder.limit(it) } + } + .build() + return query + } +} + +fun querySelect(block: QuerySelectBuilder.() -> Unit): QuerySelect = + QuerySelectBuilder().apply(block).build() + From a580f3e6442de70ede29ea8050e15ff97f2c25f1 Mon Sep 17 00:00:00 2001 From: LeandroLCD Date: Wed, 27 May 2026 17:26:22 -0400 Subject: [PATCH 36/40] feat(query): Add toQueryDelete method to QuerySelect - Implements `toQueryDelete()` in `QuerySelect` to convert a select query into a delete query targeting the same table. - Ensures that `WHERE` and subsequent logical operations (`AND`/`OR`) are preserved during the conversion. - Adds a requirement for a `WHERE` clause to be present before conversion to prevent accidental full-table deletions. - Updates extension imports to support the new query conversion functionality. --- .../com/blipblipcode/query/QuerySelect.kt | 22 +++++++++++++++++++ .../blipblipcode/query/utils/Extensions.kt | 1 + 2 files changed, 23 insertions(+) diff --git a/query/src/main/java/com/blipblipcode/query/QuerySelect.kt b/query/src/main/java/com/blipblipcode/query/QuerySelect.kt index 450e564..d1e5aec 100644 --- a/query/src/main/java/com/blipblipcode/query/QuerySelect.kt +++ b/query/src/main/java/com/blipblipcode/query/QuerySelect.kt @@ -257,6 +257,28 @@ class QuerySelect private constructor( return this.limit } + /** + * Converts this [QuerySelect] into a [QueryDelete] targeting the same table and using the same + * WHERE and AND/OR conditions. Fields, ORDER BY and LIMIT are ignored since they are not + * applicable to DELETE statements. + * + * @return A [QueryDelete] instance with the same filters as this [QuerySelect]. + * @throws IllegalArgumentException if this [QuerySelect] has no WHERE clause defined. + */ + fun toQueryDelete(): QueryDelete { + require(where != null) { "QuerySelect must have a WHERE clause to be converted to QueryDelete" } + + val deleteQuery = QueryDelete.builder(getTableName()) + .where(where!!.second.operator) + .build() + + operations.forEach { (key, operation) -> + deleteQuery.addLogicalOperation(key, operation) + } + + return deleteQuery + } + /** * Clears the LIMIT clause from the query. * @return The current `QuerySelect` instance for chaining. diff --git a/query/src/main/java/com/blipblipcode/query/utils/Extensions.kt b/query/src/main/java/com/blipblipcode/query/utils/Extensions.kt index b8a1aac..e7aec34 100644 --- a/query/src/main/java/com/blipblipcode/query/utils/Extensions.kt +++ b/query/src/main/java/com/blipblipcode/query/utils/Extensions.kt @@ -3,6 +3,7 @@ package com.blipblipcode.query.utils import androidx.sqlite.db.SimpleSQLiteQuery import androidx.sqlite.db.SupportSQLiteQuery import com.blipblipcode.query.InnerJoint +import com.blipblipcode.query.QueryDelete import com.blipblipcode.query.QuerySelect import com.blipblipcode.query.Queryable import com.blipblipcode.query.UnionQuery From 5ea199b694998326f23bc817fe735351aba1fc8c Mon Sep 17 00:00:00 2001 From: Leandro Colmenarez Date: Tue, 9 Jun 2026 17:38:08 -0400 Subject: [PATCH 37/40] refactor(query): Enhance cloning, immutability, and testing standards - Introduces a `Copyable` interface to standardize deep cloning across SQL and logical operators. - Implements `clone()` in `SQLOperator` (Equals, NotEquals, Like, In, Between, etc.), `LogicalOperation`, `Field`, and `Limit` to ensure independent instances when modifying queries. - Refactors `OrderBy` cloning to use explicit `copy()` extension methods, simplifying the API and removing unsafe `vararg` parameters. - Fixes `QuerySelect.newBuilder()` to correctly clone the operations map, preventing unintended side effects on the original query. - Adds an `andNot(key, operator)` overload to `QuerySelect.QueryBuilder` for keyed logical operation management. - Implements comprehensive unit tests for query cloning, transformation, and state maintenance. - Standardizes test naming and structure following a new `TESTING_GUIDELINES.md` (snake_case, Given-When-Then, `runCatching` for exceptions). - Updates `Between` operator SQL generation to correctly handle string conversions and spacing. --- TESTING_GUIDELINES.md | 259 ++++++++++++++++++ .../com/blipblipcode/query/QuerySelect.kt | 13 +- .../blipblipcode/query/operator/Copyable.kt | 5 + .../com/blipblipcode/query/operator/Field.kt | 4 + .../com/blipblipcode/query/operator/Limit.kt | 4 + .../query/operator/LogicalOperation.kt | 37 +-- .../blipblipcode/query/operator/OrdenBy.kt | 33 +-- .../query/operator/SQLOperator.kt | 52 +++- .../blipblipcode/query/utils/Extensions.kt | 43 +++ .../com/blipblipcode/query/QuerySelectTest.kt | 214 +++++++++------ .../com/blipblipcode/query/utils/CopyTest.kt | 31 +++ 11 files changed, 567 insertions(+), 128 deletions(-) create mode 100644 TESTING_GUIDELINES.md create mode 100644 query/src/main/java/com/blipblipcode/query/operator/Copyable.kt create mode 100644 query/src/test/java/com/blipblipcode/query/utils/CopyTest.kt diff --git a/TESTING_GUIDELINES.md b/TESTING_GUIDELINES.md new file mode 100644 index 0000000..0dec3f3 --- /dev/null +++ b/TESTING_GUIDELINES.md @@ -0,0 +1,259 @@ +# Testing Guidelines for Agents + +## 1. Test Naming Convention + +Test names must follow **snake_case** format and clearly describe: +- **What** is expected to happen (should) +- **What behavior** is being tested +- **When** that behavior occurs + - **Which method** is being tested (in_invoke, in_execute, etc.) + +### Format: +``` +should_{action}_whent_{condition}_in_{method} +``` + +### Examples: +```kotlin +@Test +fun should_remove_item_from_cart_when_quantity_is_zero_in_invoke() + +@Test +fun should_throw_QuantityMustBePositive_when_quantity_is_negative_in_invoke() + +@Test +fun should_update_quantity_when_quantity_is_valid_and_stock_is_sufficient_in_invoke() +``` + +## 2. Test Structure: Given-When-Then + +All tests must be divided into **3 clearly identified sections** with comments: + +```kotlin +@Test +fun should_do_something_when_condition_in_invoke() = runTest { + //GIVEN + // Preparation: variables, mocks, initial setup + + //WHEN + // Execution: call to the method being tested + + //THEN + // Verification: asserts and behavior verifications +} +``` + +### Complete example: +```kotlin +@Test +fun should_update_quantity_when_quantity_is_valid_and_stock_is_sufficient_in_invoke() = runTest { + //GIVEN + val productId = "1" + val quantity = 3 + val product = productBuilder { + withId(productId) + withStock(5) + } + every { productRepository.getProductById(productId) } returns flowOf(product) + + //WHEN + useCase.invoke(productId, quantity) + + //THEN + coVerify { cartItemRepository.updateQuantity(productId, quantity) } +} +``` + +## 3. Exception Testing + +To test exceptions, **use `runCatching`** to capture the result and verify the exception type: + +```kotlin +@Test +fun should_throw_QuantityMustBePositive_when_quantity_is_negative_in_invoke() = runTest { + //GIVEN + val productId = "1" + val quantity = -1 + + //WHEN + val result = runCatching { useCase.invoke(productId, quantity) } + + //THEN + assert(result.exceptionOrNull() is AppError.Validation.QuantityMustBePositive) +} +``` + +### Advantages of runCatching vs assertThrows: +- More idiomatic in Kotlin +- Allows easy inspection of exception properties +- Cleaner and clearer syntax + +### Verifying exception properties: +```kotlin +@Test +fun should_throw_InsufficientStock_when_quantity_exceeds_product_stock_in_invoke() = runTest { + //GIVEN + val productId = "1" + val quantity = 10 + val product = productBuilder { + withId(productId) + withStock(5) + } + every { productRepository.getProductById(productId) } returns flowOf(product) + + //WHEN + val result = runCatching { useCase.invoke(productId, quantity) } + + //THEN + val exception = result.exceptionOrNull() as? AppError.Validation.InsufficientStock + assert(exception != null) + assertEquals(5, exception?.available) +} +``` + +## 4. Builder Pattern for Object Creation + +To create test objects, **use the Builder pattern** with default values that allow easy creation of valid objects. + +### Location: +Builders must be in a package named `builder` at the root of the corresponding test directory. + +Example path: +``` +app/src/test/java/com/package/feature/domain/usecase/builder/ +``` + +### Builder Structure: + +```kotlin +class CartItemBuilder { + private var productId: String = "product-1" + private var quantity: Int = 2 + + fun withProductId(productId: String) = apply { this.productId = productId } + fun withQuantity(quantity: Int) = apply { this.quantity = quantity } + + fun build(): CartItem { + return CartItem(productId, quantity) + } +} + +fun cartItem(block: CartItemBuilder.() -> Unit): CartItem = + CartItemBuilder().apply(block).build() +``` + +### Using the Builder in Tests: + +#### Case 1: Using default values +```kotlin +//GIVEN +val cartItem = cartItem { } +``` + +#### Case 2: Customizing some values +```kotlin +//GIVEN +val cartItem = cartItem { + withProductId("custom-id") + withQuantity(5) +} +``` + +#### Case 3: Customizing only one value +```kotlin +//GIVEN +val cartItem = cartItem { + withQuantity(10) +} +// productId will have the default value "product-1" +``` + +### Builder Pattern Advantages: +- **Default values**: Tests are more concise, you only specify what matters +- **Readability**: Code is self-documenting +- **Maintainability**: If the model changes, you only update the builder +- **Flexibility**: Easy to create different test scenarios + +## 5. Complete Test Example + +```kotlin +class UpdateCartItemUseCaseTest { + + private lateinit var cartItemRepository: CartItemRepository + private lateinit var productRepository: ProductRepository + private lateinit var useCase: UpdateCartItemUseCase + + @Before + fun setUp() { + cartItemRepository = mockk(relaxed = true) + productRepository = mockk() + useCase = UpdateCartItemUseCase(cartItemRepository, productRepository) + } + + @Test + fun should_throw_QuantityMustBePositive_when_quantity_is_negative_in_invoke() = runTest { + //GIVEN + val productId = "1" + val quantity = -1 + + //WHEN + val result = runCatching { useCase.invoke(productId, quantity) } + + //THEN + assert(result.exceptionOrNull() is AppError.Validation.QuantityMustBePositive) + } + + @Test + fun should_remove_item_from_cart_when_quantity_is_zero_in_invoke() = runTest { + //GIVEN + val productId = "1" + val quantity = 0 + + //WHEN + useCase.invoke(productId, quantity) + + //THEN + coVerify { cartItemRepository.removeFromCart(productId) } + coVerify(exactly = 0) { cartItemRepository.updateQuantity(any(), any()) } + } + + @Test + fun should_update_quantity_when_quantity_is_valid_and_stock_is_sufficient_in_invoke() = runTest { + //GIVEN + val productId = "1" + val quantity = 3 + val product = productBuilder { + withId(productId) + withStock(5) + } + every { productRepository.getProductById(productId) } returns flowOf(product) + + //WHEN + useCase.invoke(productId, quantity) + + //THEN + coVerify { cartItemRepository.updateQuantity(productId, quantity) } + } +} +``` + +## 6. Best Practices Summary + +✅ **DO:** +- Use descriptive names in snake_case with `_in_{method}` suffix +- Divide tests into GIVEN-WHEN-THEN +- Use `runCatching` to capture exceptions +- Use builders to create test objects +- Define sensible default values in builders +- Use `runTest` for tests with coroutines + +❌ **DON'T:** +- Never include comments in the code +- Use backticks in test names +- Use `assertThrows` (prefer `runCatching`) +- Create objects manually in each test +- Use `mockk(relaxed = true)` for repositories +- Mix preparation, execution, and verification logic +- Omit the method name in the test name + + diff --git a/query/src/main/java/com/blipblipcode/query/QuerySelect.kt b/query/src/main/java/com/blipblipcode/query/QuerySelect.kt index e9b525d..caea2c6 100644 --- a/query/src/main/java/com/blipblipcode/query/QuerySelect.kt +++ b/query/src/main/java/com/blipblipcode/query/QuerySelect.kt @@ -45,8 +45,8 @@ class QuerySelect private constructor( */ fun newBuilder(consumer: (QueryBuilder) -> Unit): QueryBuilder { val mOperations = LinkedHashMap() - operations.map { (key, value) -> - operations[key] = value.clone() + operations.forEach { (key, value) -> + mOperations[key] = value.clone() } val builder = QueryBuilder(table, mOperations) where?.let { builder.where(it.first, it.second.clone()) } @@ -360,6 +360,15 @@ class QuerySelect private constructor( operations[operator.column] = LogicalOperation.AndNot(operator) return this } + /** + * Adds an AND NOT logical operation to the query. + * @param operator The SQL operator for this condition. + * @return The `QueryBuilder` instance for chaining. + */ + fun andNot(key: String, operator: SQLOperator<*>): QueryBuilder { + operations[key] = LogicalOperation.AndNot(operator) + return this + } /** * Adds an EXISTS logical operation to the query. diff --git a/query/src/main/java/com/blipblipcode/query/operator/Copyable.kt b/query/src/main/java/com/blipblipcode/query/operator/Copyable.kt new file mode 100644 index 0000000..a2bf0a6 --- /dev/null +++ b/query/src/main/java/com/blipblipcode/query/operator/Copyable.kt @@ -0,0 +1,5 @@ +package com.blipblipcode.query.operator + +interface Copyable> { + fun clone(): T +} \ No newline at end of file diff --git a/query/src/main/java/com/blipblipcode/query/operator/Field.kt b/query/src/main/java/com/blipblipcode/query/operator/Field.kt index abda93e..9783f08 100644 --- a/query/src/main/java/com/blipblipcode/query/operator/Field.kt +++ b/query/src/main/java/com/blipblipcode/query/operator/Field.kt @@ -41,4 +41,8 @@ data class Field( else -> value.toString() } } + + override fun clone(): SQLOperator { + return Field(name, value) + } } \ No newline at end of file diff --git a/query/src/main/java/com/blipblipcode/query/operator/Limit.kt b/query/src/main/java/com/blipblipcode/query/operator/Limit.kt index 51359eb..b4d8dd7 100644 --- a/query/src/main/java/com/blipblipcode/query/operator/Limit.kt +++ b/query/src/main/java/com/blipblipcode/query/operator/Limit.kt @@ -29,4 +29,8 @@ data class Limit( override fun toString(): String { return asString() } + + override fun clone(): SQLOperator { + return Limit(count, offset) + } } diff --git a/query/src/main/java/com/blipblipcode/query/operator/LogicalOperation.kt b/query/src/main/java/com/blipblipcode/query/operator/LogicalOperation.kt index 5acc8cb..8f0e828 100644 --- a/query/src/main/java/com/blipblipcode/query/operator/LogicalOperation.kt +++ b/query/src/main/java/com/blipblipcode/query/operator/LogicalOperation.kt @@ -10,7 +10,7 @@ import kotlin.collections.forEachIndexed * @property symbol The type of the logical operation (e.g., AND, OR). * @property operator The SQL operator that is part of the logical operation. */ -sealed interface LogicalOperation{ +sealed interface LogicalOperation: Copyable { val symbol: String val operator: SQLOperator<*> @@ -20,8 +20,8 @@ sealed interface LogicalOperation{ return "$symbol ${operator.toSQLString()}" } - override fun clone(vararg arg: Any?): LogicalOperation { - return Where(arg[0] as SQLOperator<*>) + override fun clone(): LogicalOperation { + return Where(operator.clone()) } } data class And(override val operator: SQLOperator<*>) : LogicalOperation { @@ -30,8 +30,8 @@ sealed interface LogicalOperation{ return "$symbol ${operator.toSQLString()}" } - override fun clone(vararg arg: Any?): LogicalOperation { - return And(arg[0] as SQLOperator<*>) + override fun clone(): LogicalOperation { + return And( operator.clone()) } } data class Or(override val operator: SQLOperator<*>) : LogicalOperation { @@ -40,8 +40,8 @@ sealed interface LogicalOperation{ return "$symbol ${operator.toSQLString()}" } - override fun clone(vararg arg: Any?): LogicalOperation { - return Or(arg[0] as SQLOperator<*>) + override fun clone(): LogicalOperation { + return Or(operator.clone()) } } data class AndNot(override val operator: SQLOperator<*>) : LogicalOperation { @@ -49,8 +49,8 @@ sealed interface LogicalOperation{ override fun asString(): String { return "$symbol ${operator.toSQLString()}" } - override fun clone(vararg arg: Any?): LogicalOperation { - return And(arg[0] as SQLOperator<*>) + override fun clone(): LogicalOperation { + return AndNot(operator.clone()) } } data class Exists(override val operator: SQLOperator<*>) : LogicalOperation{ @@ -58,8 +58,8 @@ sealed interface LogicalOperation{ override fun asString(): String { return "$symbol ${operator.toSQLString()}" } - override fun clone(vararg arg: Any?): LogicalOperation { - return Exists(arg[0] as SQLOperator<*>) + override fun clone(): LogicalOperation { + return Exists(operator.clone()) } } data class Not(override val operator: SQLOperator<*>) : LogicalOperation { @@ -67,8 +67,8 @@ sealed interface LogicalOperation{ override fun asString(): String { return "$symbol ${operator.toSQLString()}" } - override fun clone(vararg arg: Any?): LogicalOperation { - return Not(arg[0] as SQLOperator<*>) + override fun clone(): LogicalOperation { + return Not(operator.clone()) } } data class All(override val operator: SQLOperator<*>) : LogicalOperation { @@ -76,8 +76,8 @@ sealed interface LogicalOperation{ override fun asString(): String { return "$symbol ${operator.toSQLString()}" } - override fun clone(vararg arg: Any?): LogicalOperation { - return All(arg[0] as SQLOperator<*>) + override fun clone(): LogicalOperation { + return All(operator.clone()) } } @Suppress("UNCHECKED_CAST") @@ -99,8 +99,8 @@ sealed interface LogicalOperation{ } } - override fun clone(vararg arg: Any?): LogicalOperation { - return Multiple(arg[0] as List, symbol = arg[1] as? String ?: symbol) + override fun clone(): LogicalOperation { + return Multiple(operations.map { it.clone() }, symbol = symbol) } } @@ -110,5 +110,6 @@ sealed interface LogicalOperation{ */ fun asString(): String - fun clone(vararg arg: Any?): LogicalOperation + + } diff --git a/query/src/main/java/com/blipblipcode/query/operator/OrdenBy.kt b/query/src/main/java/com/blipblipcode/query/operator/OrdenBy.kt index 6d6726c..9e20ffd 100644 --- a/query/src/main/java/com/blipblipcode/query/operator/OrdenBy.kt +++ b/query/src/main/java/com/blipblipcode/query/operator/OrdenBy.kt @@ -11,6 +11,8 @@ sealed interface OrderBy : SQLOperator { val collation: Collation override fun asString(): String + override fun clone(): OrderBy + fun asSqlClause(): String { return when (this) { is Asc, is Desc -> collation.apply(transform(column)) + " " + value @@ -18,8 +20,6 @@ sealed interface OrderBy : SQLOperator { } } - fun clone(vararg params: Any?): OrderBy - data class Asc( override val column: String, override val collation: Collation = Collation.NONE, @@ -33,12 +33,8 @@ sealed interface OrderBy : SQLOperator { return "$symbol $expr $value" } - override fun clone(vararg params: Any?): OrderBy { - val newColumn = params.getOrNull(0) as? String ?: column - val newCollation = params.getOrNull(1) as? Collation ?: collation - @Suppress("UNCHECKED_CAST") - val newTransform = params.getOrNull(2) as? (String) -> String ?: transform - return this.copy(column = newColumn, transform = newTransform, collation = newCollation) + override fun clone(): OrderBy { + return this.copy() } override fun toString(): String = asString() @@ -57,12 +53,8 @@ sealed interface OrderBy : SQLOperator { return "$symbol $expr $value" } - override fun clone(vararg params: Any?): OrderBy { - val newColumn = params.getOrNull(0) as? String ?: column - val newCollation = params.getOrNull(1) as? Collation ?: collation - @Suppress("UNCHECKED_CAST") - val newTransform = params.getOrNull(2) as? (String) -> String ?: transform - return this.copy(column = newColumn, transform = newTransform, collation = newCollation) + override fun clone(): OrderBy { + return this.copy() } override fun toString(): String = asString() @@ -80,15 +72,8 @@ sealed interface OrderBy : SQLOperator { return "ORDER BY ${asSqlClause()}" } - override fun clone(vararg params: Any?): OrderBy { - val newOrders = params.getOrNull(0) as? List<*> - ?: return this.copy() - - if (newOrders.all { it is OrderBy }) { - @Suppress("UNCHECKED_CAST") - return this.copy(orders = newOrders as List) - } - throw IllegalArgumentException("The parameters provided for cloning are not of the List type.") + override fun clone(): OrderBy { + return this.copy() } override fun toString(): String = "ORDER BY ${asSqlClause()}" @@ -99,7 +84,7 @@ sealed interface OrderBy : SQLOperator { return orders == other.orders } - override fun hashCode(): Int = orders.hashCode() + override fun hashCode(): Int = orders.hashCode() + 31 * symbol.hashCode() } } \ No newline at end of file diff --git a/query/src/main/java/com/blipblipcode/query/operator/SQLOperator.kt b/query/src/main/java/com/blipblipcode/query/operator/SQLOperator.kt index 6896c22..06f927a 100644 --- a/query/src/main/java/com/blipblipcode/query/operator/SQLOperator.kt +++ b/query/src/main/java/com/blipblipcode/query/operator/SQLOperator.kt @@ -1,12 +1,15 @@ package com.blipblipcode.query.operator +import kotlinx.coroutines.CopyableThrowable +import kotlinx.coroutines.ExperimentalCoroutinesApi + /** * A sealed interface representing a SQL operator for use in WHERE clauses. * It defines the common properties of a SQL operator, such as the column, the value, and the symbol. * * @param T The type of the value being compared. */ -sealed interface SQLOperator { +sealed interface SQLOperator: Copyable> { val symbol: String val column: String val value: T @@ -36,6 +39,7 @@ sealed interface SQLOperator { */ fun asString(): String = "${caseConversion.asSqlFunction(column)} $symbol ${caseConversion.asSqlFunction(value.toString())}" + /** Represents an "=" operation. */ data class Equals( override val column: String, @@ -43,6 +47,9 @@ sealed interface SQLOperator { override val caseConversion: CaseConversion = CaseConversion.NONE ) : SQLOperator { override val symbol: String = "=" + override fun clone(): SQLOperator { + return Equals(column, value, caseConversion) + } } /** Represents a "!=" operation. */ @@ -51,6 +58,9 @@ sealed interface SQLOperator { override val value: T, override val caseConversion: CaseConversion = CaseConversion.NONE) : SQLOperator { override val symbol: String = "!=" + override fun clone(): SQLOperator { + return NotEquals(column, value, caseConversion) + } } /** Represents a ">" operation. */ @@ -59,6 +69,9 @@ sealed interface SQLOperator { override val value: T, override val caseConversion: CaseConversion = CaseConversion.NONE) : SQLOperator { override val symbol: String = ">" + override fun clone(): SQLOperator { + return GreaterThan(column, value, caseConversion) + } } /** Represents a "<" operation. */ @@ -67,6 +80,9 @@ sealed interface SQLOperator { override val value: T, override val caseConversion: CaseConversion = CaseConversion.NONE) : SQLOperator { override val symbol: String = "<" + override fun clone(): SQLOperator { + return LessThan(column, value, caseConversion) + } } /** Represents a ">=" operation. */ @@ -74,7 +90,11 @@ sealed interface SQLOperator { override val column: String, override val value: T, override val caseConversion: CaseConversion = CaseConversion.NONE) : SQLOperator { + override val symbol: String = ">=" + override fun clone(): SQLOperator { + return GreaterThanOrEqual(column, value, caseConversion) + } } /** Represents a "<=" operation. */ @@ -83,6 +103,9 @@ sealed interface SQLOperator { override val value: T, override val caseConversion: CaseConversion = CaseConversion.NONE) : SQLOperator { override val symbol: String = "<=" + override fun clone(): SQLOperator { + return LessThanOrEqual(column, value, caseConversion) + } } /** Represents a "LIKE" operation. */ @@ -91,9 +114,14 @@ sealed interface SQLOperator { override val value: String, override val caseConversion: CaseConversion = CaseConversion.NONE) : SQLOperator { override val symbol: String = "LIKE" + override fun toSQLString(): String { return "${caseConversion.asSqlFunction(column)} $symbol ${caseConversion.asSqlFunction("'%$value%'")}" } + + override fun clone(): SQLOperator { + return Like(column, value, caseConversion) + } } /** Represents an "IN" operation. */ @@ -108,6 +136,9 @@ sealed interface SQLOperator { } return "${caseConversion.asSqlFunction(column)} $symbol ($list)" } + override fun clone(): SQLOperator> { + return In(column, value, caseConversion) + } } /** Represents a "NOT IN" operation. */ @@ -122,6 +153,9 @@ sealed interface SQLOperator { } return "${caseConversion.asSqlFunction(column)} $symbol ($list)" } + override fun clone(): SQLOperator> { + return NotIn(column, value, caseConversion) + } } /** Represents an "IS NULL" operation. */ @@ -131,6 +165,9 @@ sealed interface SQLOperator { override val caseConversion: CaseConversion = CaseConversion.NONE override fun toSQLString(): String = "${caseConversion.asSqlFunction(column)} $symbol" override fun asString(): String = "${caseConversion.asSqlFunction(column)} $symbol" + override fun clone(): SQLOperator { + return IsNull(column) + } } /** Represents an "IS NOT NULL" operation. */ @@ -140,6 +177,9 @@ sealed interface SQLOperator { override val caseConversion: CaseConversion = CaseConversion.NONE override fun toSQLString(): String = "${caseConversion.asSqlFunction(column)} $symbol" override fun asString(): String = "${caseConversion.asSqlFunction(column)} $symbol" + override fun clone(): SQLOperator { + return IsNotNull(column) + } } /** Represents a "BETWEEN" operation. */ @@ -154,6 +194,14 @@ sealed interface SQLOperator { val endStr = caseConversion.asSqlFunction(end.toString()) return "${caseConversion.asSqlFunction(column)} $symbol '$startStr' AND '$endStr'" } - override fun asString(): String = "${caseConversion.asSqlFunction(column)} $symbol '${caseConversion.asSqlFunction(start.toString())}' AND '${caseConversion.asSqlFunction(start.toString())}'" + + override fun asString(): String = + "${caseConversion.asSqlFunction(column)} $symbol '${caseConversion.asSqlFunction(start.toString())}' AND '${ + caseConversion.asSqlFunction(start.toString()) + }'" + + override fun clone(): SQLOperator> { + return Between(column, start, end, caseConversion) + } } } diff --git a/query/src/main/java/com/blipblipcode/query/utils/Extensions.kt b/query/src/main/java/com/blipblipcode/query/utils/Extensions.kt index e7aec34..5b4b3ba 100644 --- a/query/src/main/java/com/blipblipcode/query/utils/Extensions.kt +++ b/query/src/main/java/com/blipblipcode/query/utils/Extensions.kt @@ -7,6 +7,20 @@ import com.blipblipcode.query.QueryDelete import com.blipblipcode.query.QuerySelect import com.blipblipcode.query.Queryable import com.blipblipcode.query.UnionQuery +import com.blipblipcode.query.operator.Collation +import com.blipblipcode.query.operator.LogicalOperation +import com.blipblipcode.query.operator.LogicalOperation.All +import com.blipblipcode.query.operator.LogicalOperation.And +import com.blipblipcode.query.operator.LogicalOperation.AndNot +import com.blipblipcode.query.operator.LogicalOperation.Exists +import com.blipblipcode.query.operator.LogicalOperation.Multiple +import com.blipblipcode.query.operator.LogicalOperation.Not +import com.blipblipcode.query.operator.LogicalOperation.Or +import com.blipblipcode.query.operator.LogicalOperation.Where +import com.blipblipcode.query.operator.OrderBy +import com.blipblipcode.query.operator.OrderBy.Asc +import com.blipblipcode.query.operator.OrderBy.Desc +import com.blipblipcode.query.operator.SQLOperator import com.blipblipcode.query.retrofit.RepeatedQueryParameters /** @@ -107,3 +121,32 @@ fun Queryable.asQueryRepeatedQueryParameters(predicate:(Pair) -> B } } } + +fun LogicalOperation.copy(operator: SQLOperator<*> = this.operator): LogicalOperation{ + return when(this){ + is All -> copy(operator = operator) + is And -> copy(operator = operator) + is AndNot -> copy(operator = operator) + is Exists -> copy( operator = operator) + is Multiple -> copy(operator = operator) + is Not -> copy(operator = operator) + is Or -> copy(operator = operator) + is Where -> copy(operator = operator) + } +} + +fun OrderBy.copy(column: String = this.column, collation: Collation = this.collation, transform: (String) -> String = this.transform): OrderBy { + return when(this){ + is Asc -> { + Asc(column, collation, transform) + } + + is Desc -> { + Desc(column, collation, transform) + } + + is OrderBy.Multiple -> { + OrderBy.Multiple(orders.map { it.copy(column, collation, transform) }) + } + } +} \ No newline at end of file diff --git a/query/src/test/java/com/blipblipcode/query/QuerySelectTest.kt b/query/src/test/java/com/blipblipcode/query/QuerySelectTest.kt index aa695d3..56d6838 100644 --- a/query/src/test/java/com/blipblipcode/query/QuerySelectTest.kt +++ b/query/src/test/java/com/blipblipcode/query/QuerySelectTest.kt @@ -5,13 +5,63 @@ import com.blipblipcode.query.operator.LogicalOperation import com.blipblipcode.query.operator.OrderBy import com.blipblipcode.query.operator.SQLOperator import com.blipblipcode.query.utils.asSQLiteQuery +import com.blipblipcode.query.utils.copy import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotEquals +import org.junit.Assert.assertTrue import org.junit.Test class QuerySelectTest { @Test - fun `remove key with an existing key`() { + fun should_new_instance_when_copying_in_transformOperation() { + + val key = "status" + + val query = QuerySelect.builder("users") + .where(SQLOperator.Equals("id", 1)) + .and(key, SQLOperator.Equals(key, "active")) + .build() + val cloneQuery = query.newBuilder { + it.transformOperation(key = key) { op -> + op.copy(SQLOperator.NotEquals(key, "active")) + } + }.build() + + assertNotEquals(query.hashCode(), cloneQuery.hashCode()) + assertNotEquals(query.asSql(), cloneQuery.asSql()) + assertTrue(cloneQuery.getSqlOperation(key) is SQLOperator.NotEquals) + } + + @Test + fun should_create_new_instance_when_copying_in_copy(){ + val query = QuerySelect.builder("users") + .where(SQLOperator.Equals("id", 1)) + .build() + val cloneQuery = query.copy() + + assertNotEquals(query.hashCode(), cloneQuery.hashCode()) + } + + @Test + fun should_maintain_state_and_create_new_instance_when_consumer_is_empty_in_newBuilder() { + val query = QuerySelect.builder("users") + .where(SQLOperator.Equals("id", 1)) + .and("status", SQLOperator.Equals("status", "active")) + .build() + + val newQuery = query.newBuilder { }.build() + + assertEquals(query.asSql(), newQuery.asSql()) + assertTrue("La nueva consulta debería ser una instancia diferente", query !== newQuery) + + val originalOp = query.getSqlOperation("status") + val newOp = newQuery.getSqlOperation("status") + assertTrue("Las operaciones internas también deberían ser instancias diferentes (clones)", originalOp !== newOp) + } + + @Test + fun should_remove_operation_when_key_exists_in_remove() { val query = QuerySelect.builder("users") .where(SQLOperator.Equals("id", 1)) .and("status", SQLOperator.Equals("status", "active")) @@ -22,7 +72,7 @@ class QuerySelectTest { } @Test - fun `remove key with a non existent key`() { + fun should_do_nothing_when_key_does_not_exist_in_remove() { val query = QuerySelect.builder("users") .where(SQLOperator.Equals("id", 1)) .build() @@ -32,7 +82,7 @@ class QuerySelectTest { } @Test - fun `remove key with an empty string key`() { + fun should_remove_operation_when_key_is_empty_in_remove() { val query = QuerySelect.builder("users") .where(SQLOperator.Equals("id", 1)) .and("", SQLOperator.Equals("status", "active")) @@ -43,7 +93,7 @@ class QuerySelectTest { } @Test - fun `remove key when operations map is empty`() { + fun should_do_nothing_when_operations_map_is_empty_in_remove() { val query = QuerySelect.builder("users") .where(SQLOperator.Equals("id", 1)) .build() @@ -53,7 +103,7 @@ class QuerySelectTest { } @Test - fun `remove key chaining call`() { + fun should_return_same_instance_for_chaining_in_remove() { val query = QuerySelect.builder("users") .where(SQLOperator.Equals("id", 1)) .and("status", SQLOperator.Equals("status", "active")) @@ -63,7 +113,7 @@ class QuerySelectTest { } @Test - fun `setWhere operator to replace an existing clause`() { + fun should_replace_existing_clause_with_new_operator_in_setWhere() { val query = QuerySelect.builder("users") .where(SQLOperator.Equals("id", 1)) .build() @@ -73,7 +123,7 @@ class QuerySelectTest { } @Test - fun `setWhere operator with a different operator type`() { + fun should_replace_existing_clause_with_different_operator_type_in_setWhere() { val query = QuerySelect.builder("users") .where(SQLOperator.Equals("id", 1)) .build() @@ -83,7 +133,7 @@ class QuerySelectTest { } @Test - fun `setWhere operator chaining call`() { + fun should_return_same_instance_for_chaining_in_setWhere() { val query = QuerySelect.builder("users") .where(SQLOperator.Equals("id", 1)) .build() @@ -92,7 +142,7 @@ class QuerySelectTest { } @Test - fun `addLogicalOperation key operation with a new key`() { + fun should_add_new_logical_operation_when_key_is_new_in_addLogicalOperation() { val query = QuerySelect.builder("users") .where(SQLOperator.Equals("id", 1)) .build() @@ -102,7 +152,7 @@ class QuerySelectTest { } @Test - fun `addLogicalOperation key operation with a duplicate key`() { + fun should_overwrite_logical_operation_when_key_is_duplicate_in_addLogicalOperation() { val query = QuerySelect.builder("users") .where(SQLOperator.Equals("id", 1)) .and("status", SQLOperator.Equals("status", "inactive")) @@ -113,7 +163,7 @@ class QuerySelectTest { } @Test - fun `addLogicalOperation key operation with an empty string key`() { + fun should_add_logical_operation_when_key_is_empty_in_addLogicalOperation() { val query = QuerySelect.builder("users") .where(SQLOperator.Equals("id", 1)) .build() @@ -123,7 +173,7 @@ class QuerySelectTest { } @Test - fun `addLogicalOperation key operation with various LogicalOperation types`() { + fun should_add_various_logical_operation_types_in_addLogicalOperation() { val query = QuerySelect.builder("users") .where(SQLOperator.Equals("id", 1)) .build() @@ -133,7 +183,7 @@ class QuerySelectTest { } @Test - fun `setFields newFields with multiple field names`() { + fun should_set_multiple_fields_in_setFields() { val query = QuerySelect.builder("users") .where(SQLOperator.Equals("id", 1)) .build() @@ -143,7 +193,7 @@ class QuerySelectTest { } @Test - fun `setFields newFields with a single field name`() { + fun should_set_single_field_in_setFields() { val query = QuerySelect.builder("users") .where(SQLOperator.Equals("id", 1)) .build() @@ -153,7 +203,7 @@ class QuerySelectTest { } @Test - fun `setFields newFields with no arguments`() { + fun should_reset_to_all_fields_when_no_arguments_provided_in_setFields() { val queryWithFields = QuerySelect.builder("users") .where(SQLOperator.Equals("id", 1)) .build() @@ -165,7 +215,7 @@ class QuerySelectTest { } @Test - fun `setFields newFields immutability check`() { + fun should_maintain_immutability_of_original_instance_in_setFields() { val originalQuery = QuerySelect.builder("users") .where(SQLOperator.Equals("id", 1)) .build() @@ -177,7 +227,7 @@ class QuerySelectTest { } @Test - fun `setFields newFields with empty or blank strings`() { + fun should_handle_empty_or_blank_field_names_in_setFields() { val query = QuerySelect.builder("users") .where(SQLOperator.Equals("id", 1)) .build() @@ -187,7 +237,7 @@ class QuerySelectTest { } @Test - fun `asSql with a basic WHERE clause and all fields`() { + fun should_generate_sql_with_basic_where_clause_and_all_fields_in_asSql() { val query = QuerySelect.builder("users") .where(SQLOperator.Equals("id", 1)) .build() @@ -196,7 +246,7 @@ class QuerySelectTest { } @Test - fun `asSql with a basic WHERE `() { + fun should_generate_sql_without_where_clause_in_asSql() { val query = QuerySelect.builder("users") .build() val expectedSql = "SELECT * FROM users" @@ -204,7 +254,7 @@ class QuerySelectTest { } @Test - fun `asSql with a WHERE clause and uppercase`() { + fun should_generate_sql_with_uppercase_conversion_in_asSql() { val query = QuerySelect.builder("users") .where(SQLOperator.Equals("id", 1)) .and("status", SQLOperator.Equals("status", "active", caseConversion = CaseConversion.UPPER)) @@ -214,7 +264,7 @@ class QuerySelectTest { } @Test - fun `asSql with specific fields and no logical operations`() { + fun should_generate_sql_with_specific_fields_in_asSql() { val query = QuerySelect.builder("users") .where(SQLOperator.Equals("id", 1)) .setFields("name", "email") @@ -224,7 +274,7 @@ class QuerySelectTest { } @Test - fun `asSql with a WHERE clause and a single logical operation`() { + fun should_generate_sql_with_single_logical_operation_in_asSql() { val query = QuerySelect.builder("users") .where(SQLOperator.Equals("id", 1)) .and("status", SQLOperator.Equals("status", "active")) @@ -234,7 +284,7 @@ class QuerySelectTest { } @Test - fun `asSql with multiple logical operations`() { + fun should_generate_sql_with_multiple_logical_operations_in_asSql() { val query = QuerySelect.builder("users") .where(SQLOperator.Equals("id", 1)) .and("status", SQLOperator.Equals("status", "active")) @@ -245,17 +295,17 @@ class QuerySelectTest { } @Test - fun `asSql with special characters in table or field names`() { - val query = QuerySelect.builder("`user table`") - .where(SQLOperator.Equals("`user id`", 1)) - .setFields("`first name`", "`last name`") + fun should_handle_special_characters_in_table_or_field_names_in_asSql() { + val query = QuerySelect.builder("user table") + .where(SQLOperator.Equals("user id", 1)) + .setFields("first name", "last name") .build() - val expectedSql = "SELECT `first name`, `last name` FROM `user table` WHERE `user id` = 1" + val expectedSql = "SELECT first name, last name FROM user table WHERE user id = 1" assertEquals(expectedSql, query.asSql().trim()) } @Test - fun `asSql after removing a logical operation`() { + fun should_reflect_removed_operation_in_asSql() { val query = QuerySelect.builder("users") .where(SQLOperator.Equals("id", 1)) .and("status", SQLOperator.Equals("status", "active")) @@ -267,7 +317,7 @@ class QuerySelectTest { } @Test - fun `asSql after changing the WHERE clause`() { + fun should_reflect_changed_where_clause_in_asSql() { val query = QuerySelect.builder("users") .where(SQLOperator.Equals("id", 1)) .build() @@ -277,7 +327,7 @@ class QuerySelectTest { } @Test - fun `asSql with empty fields list explicitly set`() { + fun should_handle_explicitly_set_empty_fields_in_asSql() { val query = QuerySelect.builder("users") .where(SQLOperator.Equals("id", 1)) .setFields() @@ -287,7 +337,7 @@ class QuerySelectTest { } @Test - fun `asSQLiteQuery converts QuerySelect to SupportSQLiteQuery`() { + fun should_convert_to_support_sqlite_query_in_asSQLiteQuery() { val querySelect = QuerySelect.builder("users") .where(SQLOperator.Equals("name", "John")) .build() @@ -299,7 +349,7 @@ class QuerySelectTest { } @Test - fun `limit results with a single limit value`() { + fun should_limit_results_with_single_value_in_limit() { val query = QuerySelect.builder("users") .where(SQLOperator.Equals("status", "active")) .limit(10) @@ -309,7 +359,7 @@ class QuerySelectTest { } @Test - fun `limit results with offset`() { + fun should_limit_results_with_offset_in_limit() { val query = QuerySelect.builder("users") .where(SQLOperator.Equals("status", "active")) .limit(5, 10) @@ -319,7 +369,7 @@ class QuerySelectTest { } @Test - fun `limit results with chaining call`() { + fun should_return_same_instance_for_chaining_in_limit() { val query = QuerySelect.builder("users") .where(SQLOperator.Equals("status", "active")) .limit(10) @@ -329,7 +379,7 @@ class QuerySelectTest { } @Test - fun `limit results with various conditions`() { + fun should_handle_various_limit_and_offset_conditions_in_limit() { val query = QuerySelect.builder("users") .where(SQLOperator.Equals("status", "active")) .limit(10, 5) @@ -339,7 +389,7 @@ class QuerySelectTest { } @Test - fun `limit results with no offset`() { + fun should_handle_zero_offset_in_limit() { val query = QuerySelect.builder("users") .where(SQLOperator.Equals("status", "active")) .limit(10, 0) @@ -349,7 +399,7 @@ class QuerySelectTest { } @Test - fun `limit results with zero limit`() { + fun should_handle_zero_limit_in_limit() { val query = QuerySelect.builder("users") .where(SQLOperator.Equals("status", "active")) .limit(0) @@ -359,7 +409,7 @@ class QuerySelectTest { } @Test - fun `limit results with negative limit`() { + fun should_handle_negative_limit_in_limit() { val query = QuerySelect.builder("users") .where(SQLOperator.Equals("status", "active")) .limit(-5) @@ -369,7 +419,7 @@ class QuerySelectTest { } @Test - fun `limit results with negative offset`() { + fun should_handle_negative_offset_in_limit() { val query = QuerySelect.builder("users") .where(SQLOperator.Equals("status", "active")) .limit(10, -5) @@ -379,7 +429,7 @@ class QuerySelectTest { } @Test - fun `orderBy with ascending order`() { + fun should_order_by_ascending_in_orderBy() { val query = QuerySelect.builder("users") .where(SQLOperator.Equals("status", "active")) .build() @@ -389,7 +439,7 @@ class QuerySelectTest { } @Test - fun `orderBy with descending order`() { + fun should_order_by_descending_in_orderBy() { val query = QuerySelect.builder("users") .where(SQLOperator.Equals("status", "active")) .build() @@ -399,7 +449,7 @@ class QuerySelectTest { } @Test - fun `orderBy with multiple columns ascending`() { + fun should_order_by_multiple_columns_ascending_in_orderBy() { val query = QuerySelect.builder("users") .where(SQLOperator.Equals("status", "active")) .build() @@ -412,7 +462,7 @@ class QuerySelectTest { } @Test - fun `orderBy with multiple columns mixed directions`() { + fun should_order_by_multiple_columns_mixed_in_orderBy() { val query = QuerySelect.builder("users") .where(SQLOperator.Equals("status", "active")) .build() @@ -425,7 +475,7 @@ class QuerySelectTest { } @Test - fun `orderBy with descending then ascending`() { + fun should_order_by_descending_then_ascending_in_orderBy() { val query = QuerySelect.builder("users") .where(SQLOperator.Equals("status", "active")) .build() @@ -438,7 +488,7 @@ class QuerySelectTest { } @Test - fun `orderBy with ascending and descending columns`() { + fun should_order_by_ascending_and_descending_columns_in_orderBy() { val query = QuerySelect.builder("users") .where(SQLOperator.Equals("status", "active")) .build() @@ -451,7 +501,7 @@ class QuerySelectTest { } @Test - fun `orderBy with limit and ascending order`() { + fun should_order_by_ascending_with_limit_in_orderBy() { val query = QuerySelect.builder("users") .where(SQLOperator.Equals("status", "active")) .limit(10) @@ -462,7 +512,7 @@ class QuerySelectTest { } @Test - fun `orderBy with limit and descending order`() { + fun should_order_by_descending_with_limit_and_offset_in_orderBy() { val query = QuerySelect.builder("users") .where(SQLOperator.Equals("status", "active")) .limit(5, 10) @@ -473,7 +523,7 @@ class QuerySelectTest { } @Test - fun `orderBy chaining call`() { + fun should_return_same_instance_for_chaining_in_orderBy() { val query = QuerySelect.builder("users") .where(SQLOperator.Equals("status", "active")) .build() @@ -482,7 +532,7 @@ class QuerySelectTest { } @Test - fun `orderBy replacing previous order`() { + fun should_replace_previous_order_in_orderBy() { val query = QuerySelect.builder("users") .where(SQLOperator.Equals("status", "active")) .build() @@ -493,7 +543,7 @@ class QuerySelectTest { } @Test - fun `orderBy with null value`() { + fun should_remove_ordering_when_null_provided_in_orderBy() { val query = QuerySelect.builder("users") .where(SQLOperator.Equals("status", "active")) .build() @@ -503,7 +553,7 @@ class QuerySelectTest { } @Test - fun `orderBy with three columns mixed directions`() { + fun should_order_by_three_columns_mixed_in_orderBy() { val query = QuerySelect.builder("users") .where(SQLOperator.Equals("status", "active")) .build() @@ -517,30 +567,30 @@ class QuerySelectTest { } @Test - fun `orderBy with special characters in column names`() { + fun should_handle_special_characters_in_column_names_in_orderBy() { val query = QuerySelect.builder("users") .where(SQLOperator.Equals("status", "active")) .build() - query.orderBy(OrderBy.Asc("`first name`")) - val expectedSql = "SELECT * FROM users WHERE status = 'active' ORDER BY `first name` ASC" + query.orderBy(OrderBy.Asc("first name")) + val expectedSql = "SELECT * FROM users WHERE status = 'active' ORDER BY first name ASC" assertEquals(expectedSql, query.asSql().trim()) } @Test - fun `orderBy with multiple columns and special characters`() { - val query = QuerySelect.builder("`user table`") - .where(SQLOperator.Equals("`user id`", 1)) + fun should_handle_multiple_special_character_column_names_in_orderBy() { + val query = QuerySelect.builder("user table") + .where(SQLOperator.Equals("user id", 1)) .build() query.orderBy(OrderBy.Multiple(listOf( - OrderBy.Asc("`first name`"), - OrderBy.Desc("`last name`") + OrderBy.Asc("first name"), + OrderBy.Desc("last name") ))) - val expectedSql = "SELECT * FROM `user table` WHERE `user id` = 1 ORDER BY `first name` ASC, `last name` DESC" + val expectedSql = "SELECT * FROM user table WHERE user id = 1 ORDER BY first name ASC, last name DESC" assertEquals(expectedSql, query.asSql().trim()) } @Test - fun `orderBy ascending with multiple logical operations`() { + fun should_order_ascending_with_multiple_logical_operations_in_orderBy() { val query = QuerySelect.builder("users") .where(SQLOperator.Equals("id", 1)) .and("status", SQLOperator.Equals("status", "active")) @@ -552,7 +602,7 @@ class QuerySelectTest { } @Test - fun `orderBy descending with multiple logical operations`() { + fun should_order_descending_with_multiple_logical_operations_in_orderBy() { val query = QuerySelect.builder("users") .where(SQLOperator.Equals("id", 1)) .and("status", SQLOperator.Equals("status", "active")) @@ -564,7 +614,7 @@ class QuerySelectTest { } @Test - fun `orderBy multiple with specific fields`() { + fun should_order_multiple_with_specific_fields_in_orderBy() { val query = QuerySelect.builder("users") .where(SQLOperator.Equals("status", "active")) .setFields("name", "email", "created_at") @@ -578,7 +628,7 @@ class QuerySelectTest { } @Test - fun `setFields with single field alias`() { + fun should_handle_single_field_alias_in_setFields() { val query = QuerySelect.builder("users") .where(SQLOperator.Equals("id", 1)) .setFields("name AS full_name") @@ -588,7 +638,7 @@ class QuerySelectTest { } @Test - fun `setFields with multiple fields with aliases`() { + fun should_handle_multiple_field_aliases_in_setFields() { val query = QuerySelect.builder("users") .where(SQLOperator.Equals("status", "active")) .setFields("name AS full_name", "email AS user_email", "created_at AS registration_date") @@ -598,7 +648,7 @@ class QuerySelectTest { } @Test - fun `setFields with mixed fields and aliases`() { + fun should_handle_mixed_fields_and_aliases_in_setFields() { val query = QuerySelect.builder("users") .where(SQLOperator.Equals("status", "active")) .setFields("id", "name AS full_name", "email") @@ -608,7 +658,7 @@ class QuerySelectTest { } @Test - fun `setFields with function and alias`() { + fun should_handle_function_with_alias_in_setFields() { val query = QuerySelect.builder("users") .where(SQLOperator.Equals("status", "active")) .setFields("COUNT(*) AS total_users", "name AS user_name") @@ -618,7 +668,7 @@ class QuerySelectTest { } @Test - fun `setFields with uppercase alias`() { + fun should_handle_uppercase_alias_in_setFields() { val query = QuerySelect.builder("users") .where(SQLOperator.Equals("id", 1)) .setFields("name AS NAME", "email AS EMAIL") @@ -628,17 +678,17 @@ class QuerySelectTest { } @Test - fun `setFields with backtick quoted alias`() { + fun should_handle_backtick_quoted_alias_in_setFields() { val query = QuerySelect.builder("users") .where(SQLOperator.Equals("id", 1)) - .setFields("`name` AS `full name`", "`email` AS `user email`") + .setFields("name AS full name", "email AS user email") .build() - val expectedSql = "SELECT `name` AS `full name`, `email` AS `user email` FROM users WHERE id = 1" + val expectedSql = "SELECT name AS full name, email AS user email FROM users WHERE id = 1" assertEquals(expectedSql, query.asSql().trim()) } @Test - fun `setFields with table prefix and alias`() { + fun should_handle_table_prefix_and_alias_in_setFields() { val query = QuerySelect.builder("users") .where(SQLOperator.Equals("users.id", 1)) .setFields("users.name AS full_name", "users.email AS user_email") @@ -648,7 +698,7 @@ class QuerySelectTest { } @Test - fun `setFields with aliases and orderBy`() { + fun should_work_with_aliases_and_orderBy_in_setFields() { val query = QuerySelect.builder("users") .where(SQLOperator.Equals("status", "active")) .setFields("name AS full_name", "created_at AS registration_date") @@ -659,7 +709,7 @@ class QuerySelectTest { } @Test - fun `setFields with aliases and limit`() { + fun should_work_with_aliases_and_limit_in_setFields() { val query = QuerySelect.builder("users") .where(SQLOperator.Equals("status", "active")) .setFields("name AS full_name", "email AS user_email") @@ -670,7 +720,7 @@ class QuerySelectTest { } @Test - fun `setFields with aliases orderBy and limit combined`() { + fun should_work_with_aliases_orderBy_and_limit_combined_in_setFields() { val query = QuerySelect.builder("users") .where(SQLOperator.Equals("status", "active")) .setFields("name AS full_name", "created_at AS registration_date", "email AS user_email") @@ -685,7 +735,7 @@ class QuerySelectTest { } @Test - fun `setFields with CASE statement and alias`() { + fun should_handle_CASE_statement_with_alias_in_setFields() { val query = QuerySelect.builder("users") .where(SQLOperator.Equals("id", 1)) .setFields("CASE WHEN status = 'active' THEN 'Active User' ELSE 'Inactive' END AS user_status") @@ -695,7 +745,7 @@ class QuerySelectTest { } @Test - fun `setFields with aggregate functions and aliases`() { + fun should_handle_aggregate_functions_with_aliases_in_setFields() { val query = QuerySelect.builder("users") .where(SQLOperator.Equals("status", "active")) .setFields("COUNT(*) AS total", "SUM(salary) AS total_salary", "AVG(salary) AS average_salary") @@ -705,7 +755,7 @@ class QuerySelectTest { } @Test - fun `setFields with mathematical expression and alias`() { + fun should_handle_mathematical_expression_with_alias_in_setFields() { val query = QuerySelect.builder("products") .where(SQLOperator.Equals("category", "electronics")) .setFields("name", "price", "price * 0.1 AS discount_amount") @@ -715,7 +765,7 @@ class QuerySelectTest { } @Test - fun `setFields with alias immutability`() { + fun should_maintain_immutability_when_using_aliases_in_setFields() { val originalQuery = QuerySelect.builder("users") .where(SQLOperator.Equals("id", 1)) .build() @@ -727,7 +777,7 @@ class QuerySelectTest { } @Test - fun `setFields replacing previous fields with aliases`() { + fun should_replace_previous_fields_with_aliases_in_setFields() { val query = QuerySelect.builder("users") .where(SQLOperator.Equals("status", "active")) .setFields("id", "name") @@ -738,7 +788,7 @@ class QuerySelectTest { } @Test - fun `setFields with multiple aliases using builder`() { + fun should_handle_multiple_aliases_using_builder_in_setFields() { val query = QuerySelect.builder("employees") .where(SQLOperator.Equals("department", "sales")) .setFields("employee_id AS id", "first_name AS fname", "last_name AS lname", "salary AS monthly_salary") diff --git a/query/src/test/java/com/blipblipcode/query/utils/CopyTest.kt b/query/src/test/java/com/blipblipcode/query/utils/CopyTest.kt new file mode 100644 index 0000000..f164b88 --- /dev/null +++ b/query/src/test/java/com/blipblipcode/query/utils/CopyTest.kt @@ -0,0 +1,31 @@ +package com.blipblipcode.query.utils + +import com.blipblipcode.query.operator.LogicalOperation +import com.blipblipcode.query.operator.OrderBy +import com.blipblipcode.query.operator.SQLOperator +import org.junit.Assert.* +import org.junit.Test + +class CopyTest { + + @Test + fun should_create_new_instance_of_LogicalOperation_when_copying_in_copy() { + val operator = LogicalOperation.Where(SQLOperator.Equals("id", 1)) + + val newOperator = operator.copy(operator = SQLOperator.NotEquals("id", 2)) + + assertNotEquals(operator, newOperator) + assertEquals(2, newOperator.operator.value) + } + + @Test + fun should_create_new_instance_of_OrderBy_when_copying_in_copy() { + val orderBy = OrderBy.Asc("id") + + val newOrderBy = orderBy.copy(column = "name") + + assertNotEquals(orderBy, newOrderBy) + assertEquals("name", newOrderBy.column) + } + +} \ No newline at end of file From 8da4ef22b53e152be4c91b87661614266135ec1c Mon Sep 17 00:00:00 2001 From: Leandro Colmenarez Date: Tue, 9 Jun 2026 17:38:52 -0400 Subject: [PATCH 38/40] refactor(query): Remove unused imports in query-related files --- query/src/main/java/com/blipblipcode/query/QuerySelect.kt | 1 - .../main/java/com/blipblipcode/query/operator/SQLOperator.kt | 3 --- query/src/main/java/com/blipblipcode/query/utils/Extensions.kt | 1 - 3 files changed, 5 deletions(-) diff --git a/query/src/main/java/com/blipblipcode/query/QuerySelect.kt b/query/src/main/java/com/blipblipcode/query/QuerySelect.kt index caea2c6..fa896af 100644 --- a/query/src/main/java/com/blipblipcode/query/QuerySelect.kt +++ b/query/src/main/java/com/blipblipcode/query/QuerySelect.kt @@ -2,7 +2,6 @@ package com.blipblipcode.query import com.blipblipcode.query.operator.Limit import com.blipblipcode.query.operator.LogicalOperation -import com.blipblipcode.query.operator.LogicalType import com.blipblipcode.query.operator.OrderBy import com.blipblipcode.query.operator.SQLOperator diff --git a/query/src/main/java/com/blipblipcode/query/operator/SQLOperator.kt b/query/src/main/java/com/blipblipcode/query/operator/SQLOperator.kt index 06f927a..e10de1b 100644 --- a/query/src/main/java/com/blipblipcode/query/operator/SQLOperator.kt +++ b/query/src/main/java/com/blipblipcode/query/operator/SQLOperator.kt @@ -1,8 +1,5 @@ package com.blipblipcode.query.operator -import kotlinx.coroutines.CopyableThrowable -import kotlinx.coroutines.ExperimentalCoroutinesApi - /** * A sealed interface representing a SQL operator for use in WHERE clauses. * It defines the common properties of a SQL operator, such as the column, the value, and the symbol. diff --git a/query/src/main/java/com/blipblipcode/query/utils/Extensions.kt b/query/src/main/java/com/blipblipcode/query/utils/Extensions.kt index 5b4b3ba..2fb9c25 100644 --- a/query/src/main/java/com/blipblipcode/query/utils/Extensions.kt +++ b/query/src/main/java/com/blipblipcode/query/utils/Extensions.kt @@ -3,7 +3,6 @@ package com.blipblipcode.query.utils import androidx.sqlite.db.SimpleSQLiteQuery import androidx.sqlite.db.SupportSQLiteQuery import com.blipblipcode.query.InnerJoint -import com.blipblipcode.query.QueryDelete import com.blipblipcode.query.QuerySelect import com.blipblipcode.query.Queryable import com.blipblipcode.query.UnionQuery From 2c25bcc467374116449e9cc9aa9c1f13f8e8bfc1 Mon Sep 17 00:00:00 2001 From: Leandro Colmenarez Date: Thu, 18 Jun 2026 14:01:55 -0400 Subject: [PATCH 39/40] refactor(query): Rename and fix cloning extension methods for `LogicalOperation` and `OrderBy` - Renames `LogicalOperation.copy()` to `copyOperation()` and `OrderBy.copy()` to `copyOrderBy()` to avoid shadowing or conflicting with data class generated `copy` methods. - Fixes `copyOperation()` implementation for `Multiple` logical operations to correctly update the first operation in the list. - Fixes `copyOperation()` for all other `LogicalOperation` types by explicitly invoking constructors instead of relying on recursive `copy()` calls. - Updates `QuerySelectTest` and `CopyTest` to use the newly renamed extension methods. - Adds KDoc documentation to both extension methods to clarify their purpose and parameters. --- .../blipblipcode/query/utils/Extensions.kt | 64 ++++++++++++------- .../com/blipblipcode/query/QuerySelectTest.kt | 4 +- .../com/blipblipcode/query/utils/CopyTest.kt | 4 +- 3 files changed, 44 insertions(+), 28 deletions(-) diff --git a/query/src/main/java/com/blipblipcode/query/utils/Extensions.kt b/query/src/main/java/com/blipblipcode/query/utils/Extensions.kt index 2fb9c25..61475c3 100644 --- a/query/src/main/java/com/blipblipcode/query/utils/Extensions.kt +++ b/query/src/main/java/com/blipblipcode/query/utils/Extensions.kt @@ -121,31 +121,47 @@ fun Queryable.asQueryRepeatedQueryParameters(predicate:(Pair) -> B } } -fun LogicalOperation.copy(operator: SQLOperator<*> = this.operator): LogicalOperation{ - return when(this){ - is All -> copy(operator = operator) - is And -> copy(operator = operator) - is AndNot -> copy(operator = operator) - is Exists -> copy( operator = operator) - is Multiple -> copy(operator = operator) - is Not -> copy(operator = operator) - is Or -> copy(operator = operator) - is Where -> copy(operator = operator) +/** + * Creates a copy of this `LogicalOperation` with a new `SQLOperator`. + * + * @param operator The new `SQLOperator` to use. + * @return A new `LogicalOperation` instance with the updated operator. + */ +fun LogicalOperation.copyOperation(operator: SQLOperator<*> = this.operator): LogicalOperation { + return when (this) { + is All -> All(operator = operator) + is And -> And(operator = operator) + is AndNot -> AndNot(operator = operator) + is Exists -> Exists(operator = operator) + is Multiple -> { + val updatedOperations = operations.toMutableList() + if (updatedOperations.isNotEmpty()) { + updatedOperations[0] = updatedOperations[0].copyOperation(operator) + } + Multiple(updatedOperations, symbol) + } + is Not -> Not(operator = operator) + is Or -> Or(operator = operator) + is Where -> Where(operator = operator) } } -fun OrderBy.copy(column: String = this.column, collation: Collation = this.collation, transform: (String) -> String = this.transform): OrderBy { - return when(this){ - is Asc -> { - Asc(column, collation, transform) - } - - is Desc -> { - Desc(column, collation, transform) - } - - is OrderBy.Multiple -> { - OrderBy.Multiple(orders.map { it.copy(column, collation, transform) }) - } +/** + * Creates a copy of this `OrderBy` clause with updated parameters. + * + * @param column The new column name. + * @param collation The new collation to use. + * @param transform An optional transformation function for the column name. + * @return A new `OrderBy` instance with the updated parameters. + */ +fun OrderBy.copyOrderBy( + column: String = this.column, + collation: Collation = this.collation, + transform: (String) -> String = this.transform +): OrderBy { + return when (this) { + is Asc -> Asc(column, collation, transform) + is Desc -> Desc(column, collation, transform) + is OrderBy.Multiple -> OrderBy.Multiple(orders.map { it.copyOrderBy(column, collation, transform) }) } -} \ No newline at end of file +} diff --git a/query/src/test/java/com/blipblipcode/query/QuerySelectTest.kt b/query/src/test/java/com/blipblipcode/query/QuerySelectTest.kt index 56d6838..b04ab9d 100644 --- a/query/src/test/java/com/blipblipcode/query/QuerySelectTest.kt +++ b/query/src/test/java/com/blipblipcode/query/QuerySelectTest.kt @@ -5,7 +5,7 @@ import com.blipblipcode.query.operator.LogicalOperation import com.blipblipcode.query.operator.OrderBy import com.blipblipcode.query.operator.SQLOperator import com.blipblipcode.query.utils.asSQLiteQuery -import com.blipblipcode.query.utils.copy +import com.blipblipcode.query.utils.copyOperation import org.junit.Assert.assertEquals import org.junit.Assert.assertNotEquals import org.junit.Assert.assertTrue @@ -24,7 +24,7 @@ class QuerySelectTest { .build() val cloneQuery = query.newBuilder { it.transformOperation(key = key) { op -> - op.copy(SQLOperator.NotEquals(key, "active")) + op.copyOperation(SQLOperator.NotEquals(key, "active")) } }.build() diff --git a/query/src/test/java/com/blipblipcode/query/utils/CopyTest.kt b/query/src/test/java/com/blipblipcode/query/utils/CopyTest.kt index f164b88..2241782 100644 --- a/query/src/test/java/com/blipblipcode/query/utils/CopyTest.kt +++ b/query/src/test/java/com/blipblipcode/query/utils/CopyTest.kt @@ -12,7 +12,7 @@ class CopyTest { fun should_create_new_instance_of_LogicalOperation_when_copying_in_copy() { val operator = LogicalOperation.Where(SQLOperator.Equals("id", 1)) - val newOperator = operator.copy(operator = SQLOperator.NotEquals("id", 2)) + val newOperator = operator.copyOperation(operator = SQLOperator.NotEquals("id", 2)) assertNotEquals(operator, newOperator) assertEquals(2, newOperator.operator.value) @@ -22,7 +22,7 @@ class CopyTest { fun should_create_new_instance_of_OrderBy_when_copying_in_copy() { val orderBy = OrderBy.Asc("id") - val newOrderBy = orderBy.copy(column = "name") + val newOrderBy = orderBy.copyOrderBy(column = "name") assertNotEquals(orderBy, newOrderBy) assertEquals("name", newOrderBy.column) From 847b6f7fd3a6a4c224346bdebfbfc87e5987cc81 Mon Sep 17 00:00:00 2001 From: Leandro Colmenarez Date: Thu, 18 Jun 2026 14:58:27 -0400 Subject: [PATCH 40/40] ci(release): Setup automated release pipeline and bump version to 0.13.3 - Updates Android Gradle Plugin (AGP) from 9.0.0 to 9.0.1. - Sets the project version to `0.13.3` in the root `build.gradle.kts`. - Introduces a comprehensive GitHub Actions release pipeline (`release-pipeline.yml`) triggered by Pull Requests to `master`. - The new pipeline automates: - Running unit tests for the `:query` module. - Validating semantic versioning and ensuring the tag does not already exist. - Building the release AAR artifact upon merging. - Creating a GitHub Release with the attached AAR and a generated changelog. - Monitoring the JitPack build status and logging. - Posting a summarized status report as a comment on the PR. --- .github/workflows/release-pipeline.yml | 512 +++++++++++++++++++++++++ build.gradle.kts | 4 +- gradle/libs.versions.toml | 2 +- 3 files changed, 516 insertions(+), 2 deletions(-) create mode 100644 .github/workflows/release-pipeline.yml diff --git a/.github/workflows/release-pipeline.yml b/.github/workflows/release-pipeline.yml new file mode 100644 index 0000000..466e983 --- /dev/null +++ b/.github/workflows/release-pipeline.yml @@ -0,0 +1,512 @@ +name: 🚀 Release Pipeline — PR to Master + +# ───────────────────────────────────────────────────────────────────────────── +# TRIGGER +# Corre en todos los eventos de PR hacia master. +# Los pasos 3-4-5 solo se ejecutan cuando el PR es mergeado. +# ───────────────────────────────────────────────────────────────────────────── +on: + pull_request: + branches: + - master + types: [opened, synchronize, reopened, closed] + +# Cancela runs previos en el mismo PR si llega un nuevo push. +# Si el PR fue mergeado, no se cancela (es el run definitivo). +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: ${{ github.event.action != 'closed' }} + +# ───────────────────────────────────────────────────────────────────────────── +# JOBS +# ───────────────────────────────────────────────────────────────────────────── +jobs: + + # ─────────────────────────────────────────────────────────────────────────── + # STEP 1 — Unit Tests + # Corre en cada push al PR (opened / synchronize / reopened) y al mergear. + # ─────────────────────────────────────────────────────────────────────────── + unit-tests: + name: 🧪 Step 1 — Unit Tests + runs-on: ubuntu-latest + if: github.event.action != 'closed' || github.event.pull_request.merged == true + timeout-minutes: 30 + + permissions: + contents: read + checks: write + pull-requests: write + + outputs: + result: ${{ steps.run-tests.outcome }} + + steps: + - name: 📥 Checkout code + uses: actions/checkout@v4 + with: + fetch-depth: 1 + + - name: ☕ Set up JDK 17 + uses: actions/setup-java@v4 + with: + distribution: 'temurin' + java-version: '17' + + - name: 🐘 Setup Gradle (cache) + uses: gradle/actions/setup-gradle@v3 + + - name: 🔧 Grant execute permission to gradlew + run: chmod +x ./gradlew + + - name: 🧪 Run :query unit tests + id: run-tests + run: | + ./gradlew :query:test \ + --no-daemon \ + --warning-mode none \ + --console=plain \ + --stacktrace + + - name: 📊 Publish unit test results + uses: EnricoMi/publish-unit-test-result-action@v2 + if: always() + with: + files: query/build/test-results/**/*.xml + check_name: 📋 Unit Test Results — :query + comment_title: 🧪 Unit Test Report — :query module + comment_mode: always + + - name: 📄 Upload test report on failure + uses: actions/upload-artifact@v4 + if: failure() + with: + name: unit-test-report-${{ github.run_number }} + path: query/build/reports/tests/ + retention-days: 14 + + # ─────────────────────────────────────────────────────────────────────────── + # STEP 2 — Check Tag Availability & Version Bump + # Verifica dos condiciones: + # a) El tag v[newVersion] NO existe aún (no duplicado). + # b) newVersion > latestTag (la versión fue efectivamente incrementada). + # ─────────────────────────────────────────────────────────────────────────── + check-tag: + name: 🏷️ Step 2 — Check Tag & Version Bump + runs-on: ubuntu-latest + if: github.event.action != 'closed' || github.event.pull_request.merged == true + timeout-minutes: 10 + + permissions: + contents: read + + outputs: + version: ${{ steps.extract-version.outputs.version }} + tag_name: ${{ steps.extract-version.outputs.tag_name }} + latest_tag: ${{ steps.validate-version.outputs.latest_tag }} + + steps: + - name: 📥 Checkout code + uses: actions/checkout@v4 + with: + fetch-depth: 0 # historial completo para leer todos los tags + + - name: 📌 Extract version from build.gradle.kts + id: extract-version + run: | + # Busca 'version = "x.x.x"' en build.gradle.kts raíz, + # con fallback al módulo query. + VERSION=$(grep -oP 'version\s*=\s*"\K[^"]+' build.gradle.kts 2>/dev/null | head -1 || true) + + if [ -z "$VERSION" ]; then + VERSION=$(grep -oP 'version\s*=\s*"\K[^"]+' query/build.gradle.kts 2>/dev/null | head -1 || true) + fi + + if [ -z "$VERSION" ]; then + echo "❌ No se encontró 'version = \"...\"' en build.gradle.kts ni en query/build.gradle.kts" + exit 1 + fi + + TAG_NAME="v${VERSION}" + echo "version=${VERSION}" >> $GITHUB_OUTPUT + echo "tag_name=${TAG_NAME}" >> $GITHUB_OUTPUT + echo "📌 Versión en Gradle: ${VERSION} → Tag a crear: ${TAG_NAME}" + + - name: "🔍 Validate: new tag > latest tag & no duplicate" + id: validate-version + run: | + NEW_VERSION="${{ steps.extract-version.outputs.version }}" + NEW_TAG="${{ steps.extract-version.outputs.tag_name }}" + + # ── Función de comparación semver (soporta MAJOR.MINOR.PATCH) ────── + # Devuelve 0 si $1 > $2, 1 en caso contrario. + semver_gt() { + # strip leading 'v' + local A="${1#v}" B="${2#v}" + # split en partes usando IFS + local IFS=. + read -ra VA <<< "$A" + read -ra VB <<< "$B" + # rellenar con ceros si faltan segmentos (e.g. 1.0 → 1.0.0) + for i in 0 1 2; do + local a="${VA[$i]:-0}" b="${VB[$i]:-0}" + if (( 10#$a > 10#$b )); then return 0 # A > B + elif (( 10#$a < 10#$b )); then return 1 # A < B + fi + done + return 1 # A == B → no es estrictamente mayor + } + + # ── Obtener el último tag semver del repo ──────────────────────── + # git tag -l lista todos; sort -V ordena semánticamente; tail toma el mayor. + LATEST_TAG=$(git tag -l 'v*' | sort -V | tail -1) + + if [ -z "$LATEST_TAG" ]; then + echo "ℹ️ No hay tags previos en el repo. Primer release: ${NEW_TAG}" + echo "latest_tag=ninguno" >> $GITHUB_OUTPUT + echo "✅ Validación superada — primer release." + exit 0 + fi + + echo "latest_tag=${LATEST_TAG}" >> $GITHUB_OUTPUT + echo "🏷️ Último tag existente : ${LATEST_TAG}" + echo "🆕 Nuevo tag a crear : ${NEW_TAG}" + + # ── a) Verificar que el tag exacto no exista ───────────────────── + if git ls-remote --tags origin "refs/tags/${NEW_TAG}" | grep -q "${NEW_TAG}"; then + echo "" + echo "❌ ERROR: El tag ${NEW_TAG} ya existe en el repositorio." + echo " Incrementa la versión en build.gradle.kts antes de mergear." + exit 1 + fi + + # ── b) Verificar que newVersion > latestTag ─────────────────────── + if semver_gt "$NEW_VERSION" "$LATEST_TAG"; then + echo "" + echo "✅ Validación superada: ${NEW_TAG} > ${LATEST_TAG}" + else + echo "" + echo "❌ ERROR: La versión ${NEW_VERSION} NO es mayor que el último tag ${LATEST_TAG}." + echo " Regla: el nuevo tag debe ser estrictamente mayor al último publicado." + echo " Ejemplo válido : ${LATEST_TAG} → v$(echo ${LATEST_TAG#v} | awk -F. '{print $1"."$2"."$3+1}')" + exit 1 + fi + + # ─────────────────────────────────────────────────────────────────────────── + # STEP 3 — Build :query Release AAR + # Depende de Step 1 y Step 2. Solo corre cuando el PR es mergeado. + # ─────────────────────────────────────────────────────────────────────────── + build-release: + name: 🏗️ Step 3 — Build :query Release AAR + runs-on: ubuntu-latest + needs: [unit-tests, check-tag] + if: github.event.pull_request.merged == true + timeout-minutes: 30 + + permissions: + contents: read + + steps: + - name: 📥 Checkout code + uses: actions/checkout@v4 + with: + fetch-depth: 1 + + - name: ☕ Set up JDK 17 + uses: actions/setup-java@v4 + with: + distribution: 'temurin' + java-version: '17' + + - name: 🐘 Setup Gradle (cache) + uses: gradle/actions/setup-gradle@v3 + + - name: 🔧 Grant execute permission to gradlew + run: chmod +x ./gradlew + + - name: 🏗️ Assemble :query Release + run: | + ./gradlew :query:assembleRelease \ + --no-daemon \ + --warning-mode none \ + --console=plain \ + --stacktrace + + - name: 🔎 Locate generated AAR + id: find-aar + run: | + AAR_PATH=$(find query/build/outputs/aar -name "*release*.aar" | head -1) + if [ -z "$AAR_PATH" ]; then + echo "❌ No se encontró ningún AAR release en query/build/outputs/aar/" + exit 1 + fi + echo "aar_path=${AAR_PATH}" >> $GITHUB_OUTPUT + echo "✅ AAR encontrado: ${AAR_PATH}" + + - name: 📦 Upload AAR as workflow artifact + uses: actions/upload-artifact@v4 + with: + name: query-release-aar + path: ${{ steps.find-aar.outputs.aar_path }} + retention-days: 7 + if-no-files-found: error + + # ─────────────────────────────────────────────────────────────────────────── + # STEP 4 — Create Tag & GitHub Release + # Depende de Step 3. Adjunta el AAR al release. + # ─────────────────────────────────────────────────────────────────────────── + create-release: + name: 🎯 Step 4 — Create Tag & GitHub Release + runs-on: ubuntu-latest + needs: [build-release, check-tag] + if: github.event.pull_request.merged == true + timeout-minutes: 10 + + permissions: + contents: write + + outputs: + release_url: ${{ steps.gh-release.outputs.url }} + + steps: + - name: 📥 Checkout code + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: 📦 Download AAR artifact + uses: actions/download-artifact@v4 + with: + name: query-release-aar + path: ./release-artifacts + + - name: 🏷️ Create GitHub Release & Tag + id: gh-release + uses: softprops/action-gh-release@v2 + with: + tag_name: ${{ needs.check-tag.outputs.tag_name }} + name: Release ${{ needs.check-tag.outputs.tag_name }} + body: | + ## 📦 android-sql-query ${{ needs.check-tag.outputs.tag_name }} + + Publicado automáticamente desde PR #${{ github.event.pull_request.number }} + **${{ github.event.pull_request.title }}** + + --- + + ### 📥 Agregar como dependencia via JitPack + + ```kotlin + // settings.gradle.kts + dependencyResolutionManagement { + repositories { + maven { url = uri("https://jitpack.io") } + } + } + + // build.gradle.kts (module) + dependencies { + implementation("com.github.LeandroLCD:android-sql-query:${{ needs.check-tag.outputs.version }}") + } + ``` + + --- + 📅 Generado el: ${{ github.event.pull_request.merged_at }} + files: ./release-artifacts/*.aar + make_latest: true + fail_on_unmatched_files: true + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + # ─────────────────────────────────────────────────────────────────────────── + # STEP 5 — JitPack Build Log + # Espera a que JitPack indexe el tag y muestra el build log. + # ─────────────────────────────────────────────────────────────────────────── + jitpack-build: + name: 📡 Step 5 — JitPack Build Log + runs-on: ubuntu-latest + needs: [create-release, check-tag] + if: github.event.pull_request.merged == true + timeout-minutes: 20 + + outputs: + jitpack_status: ${{ steps.poll-jitpack.outputs.jitpack_status }} + jitpack_log_url: ${{ steps.poll-jitpack.outputs.jitpack_log_url }} + + steps: + - name: ⏳ Initial wait — let JitPack index the new tag + run: | + echo "⏳ Esperando 40s para que JitPack indexe el tag ${{ needs.check-tag.outputs.tag_name }}..." + sleep 40 + + - name: 🚀 Trigger JitPack build & poll status + id: poll-jitpack + run: | + VERSION="${{ needs.check-tag.outputs.version }}" + GROUP="com.github.LeandroLCD" + ARTIFACT="android-sql-query" + LOG_URL="https://jitpack.io/${GROUP//.//}/${ARTIFACT}/${VERSION}/build.log" + API_URL="https://jitpack.io/api/builds/${GROUP}/${ARTIFACT}/${VERSION}" + + echo "🔗 Log URL : ${LOG_URL}" + echo "🔗 API URL : ${API_URL}" + + # Dispara la build solicitando el artefacto + echo "🚀 Disparando build en JitPack..." + curl -s -o /dev/null -w "HTTP %{http_code}\n" \ + "https://jitpack.io/${GROUP//.//}/${ARTIFACT}/${VERSION}/${ARTIFACT}-${VERSION}.aar" || true + + # Polling: máximo 15 intentos × 30s = 7.5 min + MAX=15 + ATTEMPT=0 + STATUS="unknown" + + while [ $ATTEMPT -lt $MAX ]; do + ATTEMPT=$((ATTEMPT + 1)) + echo "⏳ Intento ${ATTEMPT}/${MAX} — consultando estado en JitPack..." + + RESPONSE=$(curl -s --max-time 15 "${API_URL}" 2>/dev/null || echo '{}') + STATUS=$(echo "$RESPONSE" | python3 -c \ + "import sys,json; d=json.load(sys.stdin); print(d.get('status','unknown'))" 2>/dev/null || echo "unknown") + + echo " 📊 Status: ${STATUS}" + + if [ "$STATUS" = "ok" ]; then + echo "✅ JitPack build exitoso!" + break + elif [ "$STATUS" = "error" ]; then + echo "❌ JitPack build falló. Revisa el log:" + echo " ${LOG_URL}" + break + fi + + [ $ATTEMPT -lt $MAX ] && sleep 30 + done + + echo "jitpack_status=${STATUS}" >> $GITHUB_OUTPUT + echo "jitpack_log_url=${LOG_URL}" >> $GITHUB_OUTPUT + + - name: 📄 Print JitPack build log + if: always() + run: | + VERSION="${{ needs.check-tag.outputs.version }}" + LOG_URL="https://jitpack.io/com/github/LeandroLCD/android-sql-query/${VERSION}/build.log" + echo "════════════════════════════════════════" + echo " JitPack Build Log — ${VERSION}" + echo "════════════════════════════════════════" + curl -s --max-time 30 "${LOG_URL}" || echo "⚠️ No se pudo obtener el log aún. URL: ${LOG_URL}" + echo "════════════════════════════════════════" + + # ─────────────────────────────────────────────────────────────────────────── + # PR ANNOTATION — Resumen del pipeline como comentario en el PR + # Corre siempre al final (after all jobs), sin importar el resultado. + # ─────────────────────────────────────────────────────────────────────────── + pr-summary: + name: 📝 PR Summary Annotation + runs-on: ubuntu-latest + needs: [unit-tests, check-tag, build-release, create-release, jitpack-build] + # always() para que corra aunque algún job haya fallado o skipped + if: always() && (github.event.action != 'closed' || github.event.pull_request.merged == true) + timeout-minutes: 5 + + permissions: + pull-requests: write + + steps: + - name: 📝 Post pipeline summary comment on PR + uses: actions/github-script@v7 + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + script: | + const icon = (r) => ({ + success: '✅', failure: '❌', skipped: '⏭️', cancelled: '🚫' + }[r] ?? '⚠️'); + + const isMerged = ${{ github.event.pull_request.merged == true }}; + const version = `${{ needs.check-tag.outputs.version }}`; + const tagName = `${{ needs.check-tag.outputs.tag_name }}`; + const latestTag = `${{ needs.check-tag.outputs.latest_tag }}`; + const releaseUrl = `${{ needs.create-release.outputs.release_url }}`; + const jitpackStatus = `${{ needs.jitpack-build.outputs.jitpack_status }}`; + const jitpackLog = `${{ needs.jitpack-build.outputs.jitpack_log_url }}`; + + const r1 = `${{ needs.unit-tests.result }}`; + const r2 = `${{ needs.check-tag.result }}`; + const r3 = `${{ needs.build-release.result }}`; + const r4 = `${{ needs.create-release.result }}`; + const r5 = `${{ needs.jitpack-build.result }}`; + + const mergeRow = isMerged + ? '✅ **Mergeado** — pipeline completo ejecutado' + : '⏳ **Pendiente de merge** — solo validaciones previas'; + + const releaseLink = releaseUrl + ? `[Ver GitHub Release](${releaseUrl})` + : '—'; + + const jitpackRow = jitpackLog + ? `[📄 Build Log](${jitpackLog}) · Status: \`${jitpackStatus}\`` + : '—'; + + // Detalle de la validación semver para la tabla + const versionArrow = (latestTag && latestTag !== 'ninguno' && tagName) + ? `\`${latestTag}\` → \`${tagName}\`` + : tagName ? `primer release: \`${tagName}\`` : 'N/A'; + + const depBlock = isMerged && version ? ` + ### 📥 Dependency (JitPack) + \`\`\`kotlin + // settings.gradle.kts + maven { url = uri("https://jitpack.io") } + + // build.gradle.kts + implementation("com.github.LeandroLCD:android-sql-query:${version}") + \`\`\`` : ''; + + const warningBlock = (!isMerged && (r1 === 'failure' || r2 === 'failure')) + ? `\n> ⚠️ **Hay errores de validación.** Corrígelos antes de mergear.\n` + : ''; + + const body = `## 🚀 Release Pipeline — Resumen + + ${warningBlock} + | # | Paso | Estado | Detalle | + |---|------|--------|---------| + | 1️⃣ | Unit Tests | ${icon(r1)} \`${r1}\` | Tests unitarios del módulo \`:query\` | + | 2️⃣ | Check Tag & Bump | ${icon(r2)} \`${r2}\` | ${versionArrow} | + | 3️⃣ | Build Release AAR | ${icon(r3)} \`${r3}\` | \`./gradlew :query:assembleRelease\` | + | 4️⃣ | GitHub Release | ${icon(r4)} \`${r4}\` | Tag \`${tagName || 'N/A'}\` + AAR · ${releaseLink} | + | 5️⃣ | JitPack Build | ${icon(r5)} \`${r5}\` | ${jitpackRow} | + + **📌 Versión nueva:** \`${version || 'no detectada'}\`  ·  **🏷️ Último tag:** \`${latestTag || '—'}\` + **🔀 Estado:** ${mergeRow} + ${depBlock} + + --- + 🤖 Generado automáticamente por el Release Pipeline · Run #${{ github.run_number }}`; + + // Busca si ya existe un comentario previo del bot para actualizarlo + const comments = await github.rest.issues.listComments({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + }); + + const botComment = comments.data.find(c => + c.user.type === 'Bot' && c.body.includes('Release Pipeline — Resumen') + ); + + if (botComment) { + await github.rest.issues.updateComment({ + owner: context.repo.owner, + repo: context.repo.repo, + comment_id: botComment.id, + body, + }); + } else { + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + body, + }); + } \ No newline at end of file diff --git a/build.gradle.kts b/build.gradle.kts index 11263cc..b31e056 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -3,4 +3,6 @@ plugins { alias(libs.plugins.android.application) apply false alias(libs.plugins.kotlin.compose) apply false alias(libs.plugins.android.library) apply false -} \ No newline at end of file +} + +version = "0.13.3" diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 3424470..d5af3df 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,5 +1,5 @@ [versions] -agp = "9.0.0" +agp = "9.0.1" kotlin = "2.3.10" coreKtx = "1.18.0" junit = "4.13.2"