diff --git a/README.md b/README.md index 9dfa137..7a7f292 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) @@ -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 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" } 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..59c98ee 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. @@ -92,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) } } } @@ -126,6 +138,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 +216,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 +347,10 @@ class QuerySelect private constructor( this.orderBy = orderBy return this } + /** + * Returns the ORDER BY clause for the query. + * @return The OrderBy object, or null if not set. + */ 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/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 } 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. 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/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 efc7f91..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,43 +1,90 @@ 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(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 clone(vararg params: Any?): OrderBy { + return this.copy(column = params[0] as String) + } + override fun toString(): String { - return "ORDER BY $column DESC" + 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()}" } + 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..b4c4368 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,27 +117,29 @@ 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)" } } /** 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. */ 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 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 + } + } +} 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