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/20] 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/20] 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/20] 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/20] 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/20] 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/20] 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/20] 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/20] 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/20] 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/20] 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/20] 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/20] 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/20] 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/20] 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/20] 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/20] 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/20] 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/20] 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/20] 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/20] 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 }